diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..7de6503 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,49 @@ +--- +name: Bug report +about: Create a report to help us improve +title: "[BUG] " +labels: bug +assignees: '' + +--- + +**Describe the bug** +Provide a clear and concise description of the bug you’re encountering. Include what you were trying to achieve and how +the bug disrupts this process. + +**Steps to Reproduce** +Outline a step-by-step guide to replicate the issue. Include any relevant screenshots if applicable. This +should be detailed enough to allow someone else to follow and observe the same behavior. +Step 1: Open ... +Step 2: Click on ... +Step 3: Go to ... +Step 4: ... + +**Expected behavior** +Clearly state what you anticipated should happen during this process. This helps in understanding what the correct +outcome should be. + +**Actual behavior** +Describe the actual outcome you experienced. Be specific about how this differs from the expected behavior. + +**Debug Logs** +If you have debug logging enabled, please attach or include the relevant log excerpts that capture the issue. This +information is crucial for diagnosing the problem. + +**Screenshots** +Attach any error/issue screenshots. This visual information is crucial for diagnosing the problem. + +**Environment (please complete the following information):** + +- Operating System: [e.g., Windows 10, Ubuntu 22.04, macOS Ventura] +- Python Version: [e.g., 3.12.1] +- pym3u8downloaderui Version: [e.g., 0.1.5] +- Additional Libraries/Dependencies: (if applicable) + +**Additional context** +Include any additional information that may help in understanding or resolving the issue. This could be related to +recent changes, specific configurations, or external factors that might be influencing the bug. + +**Possible Solution** +If you have any ideas or suggestions on how to fix the issue, please outline them here. Your insights could be valuable +in accelerating the resolution of the problem. \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..e73e195 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,24 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: "[FEATURE] " +labels: enhancement +assignees: '' + +--- + +**Describe the feature request** +Provide a clear and concise description of the feature you would like to see. Explain what functionality or improvement +you are proposing and how it would enhance the current experience or system. + +**Describe the desired outcome** +Explain what you hope to achieve with this feature. Detail how it would benefit users or improve the system’s overall +performance or usability. + +**Describe alternatives considered** +If applicable, list any alternative solutions or features you’ve considered. Explain why these alternatives might not +fully address your needs or how they differ from your proposed feature. + +**Additional context** +Include any additional information that may support your feature request. This can be screenshots, mockups, use cases, +or other relevant details that help illustrate the feature’s value and implementation. \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 594631b..9f00390 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # Version History -- 0.1.4: Fixed CVE-2024-35195 (latest) +- 0.1.5: Updated to support pym3u8downloader 0.1.8 (latest) +- 0.1.4: Fixed CVE-2024-35195 - 0.1.3: Fixed CVE-2024-6345 - 0.1.2: Updated Pipeline To Support Multi-OS & Added Support for PyPI Release - 0.1.1: Introduced Linting diff --git a/README.md b/README.md index acac53b..67e870a 100644 --- a/README.md +++ b/README.md @@ -46,22 +46,34 @@ executable. ![img.png](doc_images/doc_image_executable.png) **Step 2:** Enter the URL of the .m3u8 playlist file you wish to download into the **Input URL (.m3u8)** field. + **Step 3:** Specify the destination folder for the downloaded file by clicking the **...** button. +**Step 4:** Check `Skip SSL Verification` if you want to ignore any SSL warnings for https-based input URLs. + ![img.png](doc_images/doc_image_app_window.png) -**Step 4:** Once both the input URL and output file are specified, click the **Download** button. -**Step 5:** Track the download progress within the application. +**Step 5:** Once both the input URL and output file are specified, click the **Download** button. + +**Step 6:** Track the download progress within the application. ![img.png](doc_images/doc_image_download_progress.png) -**Step 6:** Upon successful completion of the download, a confirmation message will be displayed. +**Step 7:** Upon successful completion of the download, a confirmation message will be displayed. ![img.png](doc_images/doc_image_download_complete.png) **Step 8:** To initiate a new download and reset the controls, go to **File > New**. This action resets the controls and allows you to input a new URL and select a new output file. +**Step 9:** In case if the provided input file is identified as master playlist, you will be notified as below. + +![img.png](doc_images/doc_image_master_playlist_identified.png) + +**Step 10:** From the available variants, select the appropriate one and click on **Download** button again. + +![img.png](doc_images/doc_image_master_playlist_variants.png) + ## General Issues & Resolutions ### Invalid Input URL diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..b38c1d7 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,39 @@ +# Security Policy + +We only provide updates for the latest version of `pym3u8downloaderui`. Please ensure you are using the most recent +version, which is currently: + +| Version | Supported | +|---------|--------------------| +| 0.1.5 | :white_check_mark: | + +If you are using an older version, we highly recommend upgrading to the latest version to ensure you have the latest +security updates. + +## Reporting a Vulnerability + +If you discover a security vulnerability in `pym3u8downloaderui`, we encourage you to report it using GitHub's Private +Vulnerability Reporting feature. This method ensures that your report remains confidential and is handled securely. + +### How to Report a Vulnerability + +1. **Navigate to the Repository**: Go to the [pym3u8downloaderui](https://github.com/coldsofttech/pym3u8downloaderui) + GitHub Repository. + +2. **Access Security Advisories**: Click on the "Security" tab, then select "Report a Vulnerability." + +3. **Provide Detailed Information**: + - **Description**: Give a clear and concise description of the vulnerability. + - **Reproduction Steps**: Include steps to reproduce the vulnerability. + - **Impact**: Describe the potential impact of the vulnerability. + +4. **Submit the Report**: Once all necessary details are provided, submit the report. We will review and acknowledge + your report as soon as possible. + +### Best Effort on Resolution + +While we will strive to resolve the reported vulnerability as promptly as possible, we cannot guarantee a specific +timeline for the resolution. We will keep you updated on the progress and may reach out for additional information if +needed. + +Thank you for helping to keep `pym3u8downloaderui` secure! \ No newline at end of file diff --git a/doc_images/doc_image_app_window.png b/doc_images/doc_image_app_window.png index febfdd9..8f314be 100644 Binary files a/doc_images/doc_image_app_window.png and b/doc_images/doc_image_app_window.png differ diff --git a/doc_images/doc_image_download_progress.png b/doc_images/doc_image_download_progress.png index 005c37a..96c000c 100644 Binary files a/doc_images/doc_image_download_progress.png and b/doc_images/doc_image_download_progress.png differ diff --git a/doc_images/doc_image_master_playlist_identified.png b/doc_images/doc_image_master_playlist_identified.png new file mode 100644 index 0000000..7d443bf Binary files /dev/null and b/doc_images/doc_image_master_playlist_identified.png differ diff --git a/doc_images/doc_image_master_playlist_variants.png b/doc_images/doc_image_master_playlist_variants.png new file mode 100644 index 0000000..3f34d11 Binary files /dev/null and b/doc_images/doc_image_master_playlist_variants.png differ diff --git a/requirements.txt b/requirements.txt index 0a01a42..5af2cfa 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,3 @@ -pym3u8downloader -pytest~=7.4.0 -setuptools~=72.1.0 -requests>=2.32.0 \ No newline at end of file +pym3u8downloader~=0.1.8 +setuptools~=74.0.0 +requests~=2.32.3 \ No newline at end of file diff --git a/setup.py b/setup.py index 3eaa099..b7f9a1d 100644 --- a/setup.py +++ b/setup.py @@ -16,8 +16,8 @@ ] }, install_requires=[ - 'requests', - 'pym3u8downloader' + 'requests~=2.32.3', + 'pym3u8downloader~=0.1.8' ], requires_python=">=3.10", long_description=open('README.md').read(), diff --git a/src/__init__.py b/src/__init__.py index 40fdb62..3c1f3d4 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -1,46 +1,20 @@ import json import os import platform +import re import sys import threading import tkinter as tk import webbrowser from tkinter import messagebox, ttk, filedialog - - -class StdoutRedirector: - """Class for redirecting standard output to a Tkinter text variable.""" - - def __init__(self, text_variable) -> None: - """ - Initialize the StdoutRedirector class. - - :param text_variable: Tkinter text variable to store the output. - :type text_variable: tk.StringVar - """ - self.text_variable = text_variable - self.buffer = '' - - def write(self, message) -> None: - """ - Write the message to the text variable. - - :param message: The message to be written. - :type message: str - :return: None - """ - self.text_variable.set(message) - - def flush(self) -> None: - """Flush the output buffer.""" - pass +from typing import Optional class Constants: """This class holds all the constant values used in the application""" APP_TITLE = 'M3U8 Downloader' # Title of the application - APP_VERSION = '0.1.4' # Version of the application + APP_VERSION = '0.1.5' # Version of the application APP_PACKAGE_NAME = 'pym3u8downloaderui' # Package name of the application APP_PACKAGE_DESCRIPTION = """ M3U8 Downloader UI is a Python-based graphical user interface (GUI) application designed @@ -49,7 +23,7 @@ class Constants: merging video files from M3U8 playlists. """ # Description of the application APP_WINDOW_WIDTH = 450 # Width of the application window - APP_WINDOW_HEIGHT = 250 # Height of the application window + APP_WINDOW_HEIGHT = 350 # Height of the application window APP_PALETTE_BACKGROUND = '#FFFFFF' # Background color of the application APP_PALETTE_FOREGROUND = '#000000' # Foreground color of the application APP_THEME = 'xpnative' if platform.system().lower() == 'windows' else 'clam' # Theme of the application @@ -78,6 +52,8 @@ class Constants: LABEL_INPUT_TITLE = 'Input URL (.m3u8):' # Title for input URL label LABEL_OUTPUT_TITLE = 'Output File (.mp4):' # Title for output file label + LABEL_SKIP_SSL_VERIFICATION_TITLE = 'Skip SSL Verification' # Title for skip SSL verification label + LABEL_MASTER_CONFIGURATION_TITLE = 'Variants:' # Title for variants available in the master playlist BUTTON_BROWSE_TITLE = '...' # Title for browse button BUTTON_DOWNLOAD_TITLE = 'Download' # Title for download button @@ -94,6 +70,10 @@ class Constants: DOWNLOAD_COMPLETE_TITLE = 'Download' # Title for download complete message DOWNLOAD_COMPLETE_MESSAGE = 'Download completed successfully!' # Message for download complete message DOWNLOAD_ERROR_TITLE = 'Error' # Title for download error message + DOWNLOAD_MASTER_IDENTIFIED_TITLE = 'Master Playlist' # Title for playlist identified as master + DOWNLOAD_MASTER_IDENTIFIED_MESSAGE = ( + 'Identified m3u8 file as master playlist. Select appropriate configuration for download.' + ) # Message for playlist identified as master ABOUT_TITLE = 'About' # Title for about window ABOUT_WINDOW_WIDTH = 300 # Width of the about window @@ -101,6 +81,163 @@ class Constants: CONFIG_FILE = 'config.json' # File name for configuration file + PATTERN_MASTER_VARIANT_NAME = r'Name:\s*(?P[^|]+)' # Regex pattern for capturing name from the variant + # Regex pattern for capturing bandwidth from the variant + PATTERN_MASTER_VARIANT_BANDWIDTH = r'Bandwidth:\s*(?P[^|]+)' + # Regex pattern for capturing resolution from the variant + PATTERN_MASTER_VARIANT_RESOLUTION = r'Resolution:\s*(?P[^|]+)' + + +class StdoutRedirector: + """Class for redirecting standard output to a Tkinter text variable.""" + + def __init__(self, text_variable) -> None: + """ + Initialize the StdoutRedirector class. + + :param text_variable: Tkinter text variable to store the output. + :type text_variable: tk.StringVar + """ + self.text_variable = text_variable + self.buffer = '' + + def write(self, message) -> None: + """ + Write the message to the text variable. + + :param message: The message to be written. + :type message: str + :return: None + """ + self.text_variable.set(message) + + def flush(self) -> None: + """Flush the output buffer.""" + pass + + +class AboutUI: + """ + Class for creating an 'About' window in the M3U8 Downloader application. + """ + + def __init__(self, parent: tk.Tk) -> None: + """ + Initialize the AboutUI class. + + :param parent: The parent Tkinter root window. + :type parent: tk.Tk + """ + self.parent = parent + self.window = tk.Toplevel(self.parent) + self.window.title(Constants.ABOUT_TITLE) + self.window.resizable(False, False) + self.window.transient(self.parent) + self.window.grab_set() + + self._set_window_size() + self._set_defaults() + self._set_styles() + self._set_fonts() + self._set_controls() + + def _set_window_size(self) -> None: + """Set the size and position of the about window.""" + self.window_width = Constants.ABOUT_WINDOW_WIDTH + self.window_height = Constants.ABOUT_WINDOW_HEIGHT + screen_width = self.window.winfo_screenwidth() + screen_height = self.window.winfo_screenheight() + x = (screen_width - self.window_width) // 2 + y = (screen_height - self.window_height) // 2 + self.window.geometry(f'{self.window_width}x{self.window_height}+{x}+{y}') + + def _set_defaults(self) -> None: + """Set default values for various attributes.""" + self.header = 'About this app' + self.app_name = 'M3U8 Downloader' + self.app_version = Constants.APP_VERSION + self.copyrights = '© 2024 coldsofttech' + self.license_type = 'MIT License' + self.license_link = 'https://raw.githubusercontent.com/coldsofttech/pym3u8downloaderui/main/LICENSE' + + def _set_styles(self) -> None: + """Set styles for the about window.""" + self.window.tk_setPalette( + background=Constants.APP_PALETTE_BACKGROUND, foreground=Constants.APP_PALETTE_FOREGROUND + ) + self.style = ttk.Style() + self.style.theme_use(Constants.APP_THEME) + + self.no_background_style = ttk.Style() + self.no_background_style.configure('NoBackground.TLabel', background=self.window.cget('background')) + + def _set_fonts(self) -> None: + """Set font styles for labels.""" + self.font_header_style = (Constants.APP_LABEL_FONT_TYPE, 15, Constants.APP_LABEL_FONT_STYLE) + self.font_label_style = (Constants.APP_LABEL_FONT_TYPE, 10) + + def _set_controls(self) -> None: + """Set up various controls/widgets in the about window.""" + self.window.rowconfigure(0, minsize=Constants.APP_ROW_MIN_SIZE) + + self.header_label = ttk.Label( + self.window, text=self.header, font=self.font_header_style, style='NoBackground.TLabel' + ) + self.header_label.grid(row=1, column=0, sticky=tk.W, padx=Constants.APP_PADDING) + + self.window.rowconfigure(2, minsize=Constants.APP_ROW_MIN_SIZE) + + self._show_icon() + + self.window.rowconfigure(4, minsize=Constants.APP_ROW_MIN_SIZE) + + self.app_name_label = ttk.Label( + self.window, text=f'{self.app_name} {self.app_version}', font=self.font_label_style, + style='NoBackground.TLabel' + ) + self.app_name_label.grid(row=5, column=0, sticky=tk.W, padx=Constants.APP_PADDING) + + self.copyright_label = ttk.Label( + self.window, text=self.copyrights, font=self.font_label_style, style='NoBackground.TLabel' + ) + self.copyright_label.grid(row=6, column=0, sticky=tk.W, padx=Constants.APP_PADDING) + + self.window.rowconfigure(7, minsize=Constants.APP_ROW_MIN_SIZE) + + self.license_label = ttk.Label( + self.window, text=self.license_type, foreground='blue', cursor='hand2', font=self.font_label_style, + style='NoBackground.TLabel' + ) + self.license_label.grid(row=8, column=0, sticky=tk.W, padx=Constants.APP_PADDING) + self.license_label.bind('', self._open_license) + + def _show_icon(self) -> None: + """Show icon for the application.""" + import requests + + try: + response = requests.get(Constants.APP_ICON_IMAGE_FILE_PATH) + if response.status_code == 200: + image_data = response.content + self.image = tk.PhotoImage(data=image_data) + self.image = self.image.subsample(int(self.image.width() / 64), int(self.image.height() / 64)) + + self.icon_label = ttk.Label(self.window, image=self.image) + self.icon_label.image = self.image + self.icon_label.grid(row=3, column=0, sticky=tk.W, padx=Constants.APP_PADDING) + except requests.RequestException: + pass + + def _open_license(self, event) -> None: + """ + Open the license link in a web browser. + + :param event: Mouse click event. + :type event: tkinter.Event + :return: None + """ + webbrowser.open(self.license_link) + class M3U8DownloaderUI: """ @@ -143,7 +280,6 @@ def _set_icon(self) -> None: self.master.iconbitmap(default=Constants.APP_ICON_FILE_NAME) except requests.RequestException: pass - # elif platform.system().lower() == 'linux': else: if os.path.exists(Constants.APP_ICON_IMAGE_FILE_NAME): icon = tk.PhotoImage(file=Constants.APP_ICON_IMAGE_FILE_NAME) @@ -166,6 +302,7 @@ def _set_overrides(self) -> None: def _set_defaults(self) -> None: """Set default value for various attributes.""" self.selected_file_path = tk.StringVar() + self.skip_ssl = tk.BooleanVar(value=False) self.std_output = tk.StringVar() self.download_thread = None self.help_link = 'https://github.com/coldsofttech/pym3u8downloaderui/blob/main/README.md' @@ -229,21 +366,42 @@ def _set_controls(self) -> None: self.master.rowconfigure(6, minsize=Constants.APP_ROW_MIN_SIZE) + self.skip_ssl_checkbox = ttk.Checkbutton( + self.master, text=Constants.LABEL_SKIP_SSL_VERIFICATION_TITLE, variable=self.skip_ssl + ) + self.skip_ssl_checkbox.grid(row=7, column=0, sticky=tk.W, padx=(10, Constants.APP_PADDING)) + + self.configuration_label = ttk.Label( + self.master, text=Constants.LABEL_MASTER_CONFIGURATION_TITLE, font=self.font_label_style + ) + self.configuration_label.grid(row=8, column=0, sticky=tk.W, padx=Constants.APP_PADDING) + self.configuration_label.grid_remove() + + self.variants_combobox = ttk.Combobox( + self.master, state='readonly', width=65 + ) + self.variants_combobox.grid(row=9, column=0, columnspan=2, sticky=tk.W, padx=(10, Constants.APP_PADDING)) + self.variants_combobox.grid_remove() + + self.master.rowconfigure(10, minsize=Constants.APP_ROW_MIN_SIZE) + self.download_button = ttk.Button( self.master, text=Constants.BUTTON_DOWNLOAD_TITLE, command=self._download_button_callback ) - self.download_button.grid(row=7, column=0, columnspan=2, sticky=tk.E, padx=Constants.APP_PADDING) + self.download_button.grid(row=11, column=0, columnspan=2, sticky=tk.E, padx=Constants.APP_PADDING) - self.master.rowconfigure(8, minsize=Constants.APP_ROW_MIN_SIZE) + self.master.rowconfigure(12, minsize=Constants.APP_ROW_MIN_SIZE) self.stdout_label = ttk.Label(self.master, textvariable=self.std_output, wraplength=430) - self.stdout_label.grid(row=9, column=0, columnspan=2, sticky=tk.W, padx=Constants.APP_PADDING) + self.stdout_label.grid(row=13, column=0, columnspan=2, sticky=tk.W, padx=Constants.APP_PADDING) def disable_controls(self) -> None: """Disable all user controls.""" self.input_entry.config(state=tk.DISABLED) self.output_entry.config(state=tk.DISABLED) self.select_output_button.config(state=tk.DISABLED) + self.skip_ssl_checkbox.config(state=tk.DISABLED) + self.variants_combobox.config(state=tk.DISABLED) self.download_button.config(state=tk.DISABLED) self.file_menu.entryconfig(Constants.MENU_FILE_NEW_TITLE, state=tk.DISABLED) @@ -252,10 +410,39 @@ def enable_controls(self) -> None: self.input_entry.config(state=tk.NORMAL) self.output_entry.config(state='readonly') self.select_output_button.config(state=tk.NORMAL) + self.skip_ssl_checkbox.config(state=tk.NORMAL) + self.variants_combobox.config(state=tk.NORMAL) self.download_button.config(state=tk.NORMAL) self.file_menu.entryconfig(Constants.MENU_FILE_NEW_TITLE, state=tk.NORMAL) - def _download_playlist(self, input_url: str, output_file: str) -> None: + def show_master_configuration_controls(self, variants: list) -> None: + """Shows variant information in case playlist is identified as master.""" + self.configuration_label.grid() + self.configuration_label.grid_rowconfigure(8, weight=1) + self.configuration_label.grid_columnconfigure(0, weight=1) + self.variants_combobox.grid() + self.variants_combobox.grid_rowconfigure(9, weight=1) + self.variants_combobox.grid_columnconfigure(0, weight=1) + self.variants_combobox['values'] = variants + if variants: + self.variants_combobox.current(0) + + def hide_master_configuration_controls(self) -> None: + """Hides variant information in case playlist is not identified as master.""" + self.configuration_label.grid_remove() + self.variants_combobox.grid_remove() + self.variants_combobox['values'] = None + + def _download_playlist( + self, + input_url: str, + output_file: str, + verify_ssl: bool, + is_master: bool, + variant_name: Optional[str] = None, + variant_bandwidth: Optional[str] = None, + variant_resolution: Optional[str] = None + ) -> None: """ Download the playlist from the given input URL. @@ -263,21 +450,48 @@ def _download_playlist(self, input_url: str, output_file: str) -> None: :type input_url: str :param output_file: Output file (.mp4). :type output_file: str + :param verify_ssl: A flag to indicate if SSL warning needs skip. + :type verify_ssl: bool + :param is_master: A flag to indicate if playlist is master. + :type is_master: bool + :param variant_name: The name of the variant in case of master playlist. + :type variant_name: str + :param variant_bandwidth: The bandwidth of the variant in case of master playlist. + :type variant_bandwidth: str + :param variant_resolution: The resolution of the variant in case of master playlist. + :type variant_resolution: str :return: None """ - self.download_thread = DownloadThread(input_url, output_file, self) + self.download_thread = DownloadThread( + input_url, output_file, verify_ssl, is_master, self, variant_name, variant_bandwidth, variant_resolution + ) self.download_thread.start() def _download_button_callback(self) -> None: """Callback function for the download button.""" input_url = self.input_entry.get() output_file = self.output_entry.get() + skip_ssl = self.skip_ssl.get() + variant = self.variants_combobox.get() if not input_url or not output_file: messagebox.showwarning(Constants.INVALID_INPUT_TITLE, Constants.INVALID_INPUT_MESSAGE) return - self._download_playlist(input_url, output_file) + if variant: + variant_name_match = re.search(Constants.PATTERN_MASTER_VARIANT_NAME, variant) + variant_bandwidth_match = re.search(Constants.PATTERN_MASTER_VARIANT_BANDWIDTH, variant) + variant_resolution_match = re.search(Constants.PATTERN_MASTER_VARIANT_RESOLUTION, variant) + variant_name = variant_name_match.group('name').strip() if variant_name_match else None + variant_bandwidth = variant_bandwidth_match.group('bandwidth').strip() if variant_bandwidth_match else None + variant_resolution = ( + variant_resolution_match.group('resolution').strip() if variant_resolution_match else None + ) + self._download_playlist( + input_url, output_file, not skip_ssl, True, variant_name, variant_bandwidth, variant_resolution + ) + else: + self._download_playlist(input_url, output_file, not skip_ssl, False) def _select_output_button_callback(self) -> None: """Callback function for the select output button.""" @@ -292,6 +506,8 @@ def _new_callback(self) -> None: """Callback function for the 'New' option in the file menu.""" self.input_entry.delete(0, tk.END) self.selected_file_path = '' + self.skip_ssl = False + self.hide_master_configuration_controls() def _exit_callback(self) -> None: """Callback function for the 'Exit' option in the file menu.""" @@ -309,7 +525,17 @@ def _help_callback(self) -> None: class DownloadThread(threading.Thread): """Thread class for downloading M3U8 playlists in a separate thread.""" - def __init__(self, input_url: str, output_file: str, source: M3U8DownloaderUI) -> None: + def __init__( + self, + input_url: str, + output_file: str, + verify_ssl: bool, + is_master: bool, + source: M3U8DownloaderUI, + variant_name: Optional[str] = None, + variant_bandwidth: Optional[str] = None, + variant_resolution: Optional[str] = None + ) -> None: """ Initialize the DownloadThread class. @@ -317,12 +543,27 @@ def __init__(self, input_url: str, output_file: str, source: M3U8DownloaderUI) - :type input_url: str :param output_file: Output file (.mp4). :type output_file: str + :param verify_ssl: A flag to indicate if SSL warning needs skip. + :type verify_ssl: bool + :param is_master: A flag to indicate if playlist is master. + :type is_master: bool :param source: The source M3U8DownloaderUI instance. :type source: M3U8DownloaderUI + :param variant_name: The name of the variant in case of master playlist. + :type variant_name: str + :param variant_bandwidth: The bandwidth of the variant in case of master playlist. + :type variant_bandwidth: str + :param variant_resolution: The resolution of the variant in case of master playlist. + :type variant_resolution: str """ super().__init__() self.input_url = input_url self.output_file = output_file + self.verify_ssl = verify_ssl + self.is_master = is_master + self.variant_name = variant_name + self.variant_bandwidth = variant_bandwidth + self.variant_resolution = variant_resolution self.source = source self.skip_space_check = False self.debug = False @@ -337,144 +578,52 @@ def _load_config(self) -> None: def run(self) -> None: """Run the download process in a separate thread.""" - from pym3u8downloader import M3U8Downloader, M3U8DownloaderError + from pym3u8downloader import M3U8Downloader, M3U8DownloaderError, M3U8DownloaderWarning + + downloader = None try: self._load_config() self.source.disable_controls() sys.stdout = StdoutRedirector(self.source.std_output) - downloader = M3U8Downloader(self.input_url, self.output_file, self.skip_space_check, self.debug) - downloader.download_playlist() + downloader = M3U8Downloader( + input_file_path=self.input_url, + output_file_path=self.output_file, + skip_space_check=self.skip_space_check, + debug=self.debug, + verify_ssl=self.verify_ssl + ) + if not self.is_master: + downloader.download_playlist() + else: + downloader.download_master_playlist(self.variant_name, self.variant_bandwidth, self.variant_resolution) messagebox.showinfo(Constants.DOWNLOAD_COMPLETE_TITLE, Constants.DOWNLOAD_COMPLETE_MESSAGE) except (OSError, ValueError, TypeError, M3U8DownloaderError) as e: - messagebox.showerror(Constants.DOWNLOAD_ERROR_TITLE, str(e)) + if 'as master playlist' in e.message: + try: + downloader.download_master_playlist() + except M3U8DownloaderWarning as warn: + messagebox.showinfo( + Constants.DOWNLOAD_MASTER_IDENTIFIED_TITLE, Constants.DOWNLOAD_MASTER_IDENTIFIED_MESSAGE + ) + formatted_variants = [ + ( + f'Name: {variant.get("Name", "")} | ' + f'Bandwidth: {variant.get("bandwidth", "")} | ' + f'Resolution: {variant.get("resolution", "")}' + ) + for variant in warn.json_data + ] + self.source.show_master_configuration_controls(formatted_variants) + elif 'as playlist' in e.message: + self.source.hide_master_configuration_controls() + downloader.download_playlist() + else: + messagebox.showerror(Constants.DOWNLOAD_ERROR_TITLE, str(e)) finally: self.source.enable_controls() -class AboutUI: - """ - Class for creating an 'About' window in the M3U8 Downloader application. - """ - - def __init__(self, parent: tk.Tk) -> None: - """ - Initialize the AboutUI class. - - :param parent: The parent Tkinter root window. - :type parent: tk.Tk - """ - self.parent = parent - self.window = tk.Toplevel(self.parent) - self.window.title(Constants.ABOUT_TITLE) - self.window.resizable(False, False) - self.window.transient(self.parent) - self.window.grab_set() - - self._set_window_size() - self._set_defaults() - self._set_styles() - self._set_fonts() - self._set_controls() - - def _set_window_size(self) -> None: - """Set the size and position of the about window.""" - self.window_width = Constants.ABOUT_WINDOW_WIDTH - self.window_height = Constants.ABOUT_WINDOW_HEIGHT - screen_width = self.window.winfo_screenwidth() - screen_height = self.window.winfo_screenheight() - x = (screen_width - self.window_width) // 2 - y = (screen_height - self.window_height) // 2 - self.window.geometry(f'{self.window_width}x{self.window_height}+{x}+{y}') - - def _set_defaults(self) -> None: - """Set default values for various attributes.""" - self.header = 'About this app' - self.app_name = 'M3U8 Downloader' - self.app_version = Constants.APP_VERSION - self.copyrights = '© 2024 coldsofttech' - self.license_type = 'MIT License' - self.license_link = 'https://raw.githubusercontent.com/coldsofttech/pym3u8downloaderui/main/LICENSE' - - def _set_styles(self) -> None: - """Set styles for the about window.""" - self.window.tk_setPalette( - background=Constants.APP_PALETTE_BACKGROUND, foreground=Constants.APP_PALETTE_FOREGROUND - ) - self.style = ttk.Style() - self.style.theme_use(Constants.APP_THEME) - - self.no_background_style = ttk.Style() - self.no_background_style.configure('NoBackground.TLabel', background=self.window.cget('background')) - - def _set_fonts(self) -> None: - """Set font styles for labels.""" - self.font_header_style = (Constants.APP_LABEL_FONT_TYPE, 15, Constants.APP_LABEL_FONT_STYLE) - self.font_label_style = (Constants.APP_LABEL_FONT_TYPE, 10) - - def _set_controls(self) -> None: - """Set up various controls/widgets in the about window.""" - self.window.rowconfigure(0, minsize=Constants.APP_ROW_MIN_SIZE) - - self.header_label = ttk.Label( - self.window, text=self.header, font=self.font_header_style, style='NoBackground.TLabel' - ) - self.header_label.grid(row=1, column=0, sticky=tk.W, padx=Constants.APP_PADDING) - - self.window.rowconfigure(2, minsize=Constants.APP_ROW_MIN_SIZE) - - self._show_icon() - - self.window.rowconfigure(4, minsize=Constants.APP_ROW_MIN_SIZE) - - self.app_name_label = ttk.Label( - self.window, text=f'{self.app_name} {self.app_version}', font=self.font_label_style, - style='NoBackground.TLabel' - ) - self.app_name_label.grid(row=5, column=0, sticky=tk.W, padx=Constants.APP_PADDING) - - self.copyright_label = ttk.Label( - self.window, text=self.copyrights, font=self.font_label_style, style='NoBackground.TLabel' - ) - self.copyright_label.grid(row=6, column=0, sticky=tk.W, padx=Constants.APP_PADDING) - - self.window.rowconfigure(7, minsize=Constants.APP_ROW_MIN_SIZE) - - self.license_label = ttk.Label( - self.window, text=self.license_type, foreground='blue', cursor='hand2', font=self.font_label_style, - style='NoBackground.TLabel' - ) - self.license_label.grid(row=8, column=0, sticky=tk.W, padx=Constants.APP_PADDING) - self.license_label.bind('', self._open_license) - - def _show_icon(self) -> None: - """Show icon for the application.""" - import requests - - try: - response = requests.get(Constants.APP_ICON_IMAGE_FILE_PATH) - if response.status_code == 200: - image_data = response.content - self.image = tk.PhotoImage(data=image_data) - self.image = self.image.subsample(int(self.image.width() / 64), int(self.image.height() / 64)) - - self.icon_label = ttk.Label(self.window, image=self.image) - self.icon_label.image = self.image - self.icon_label.grid(row=3, column=0, sticky=tk.W, padx=Constants.APP_PADDING) - except requests.RequestException: - pass - - def _open_license(self, event) -> None: - """ - Open the license link in a web browser. - - :param event: Mouse click event. - :type event: tkinter.Event - :return: None - """ - webbrowser.open(self.license_link) - - def main(): root = tk.Tk() M3U8DownloaderUI(root) diff --git a/tests/unit/test_aboutui.py b/tests/test_aboutui.py similarity index 100% rename from tests/unit/test_aboutui.py rename to tests/test_aboutui.py diff --git a/tests/unit/test_downloadthread.py b/tests/test_downloadthread.py similarity index 91% rename from tests/unit/test_downloadthread.py rename to tests/test_downloadthread.py index 4d1a6d7..59f7c4c 100644 --- a/tests/unit/test_downloadthread.py +++ b/tests/test_downloadthread.py @@ -15,6 +15,8 @@ def setUp(self): self.root = tk.Tk() self.input_url = 'https://raw.githubusercontent.com/coldsofttech/pym3u8downloader/main/tests/files/index.m3u8' self.output_file = 'video.mp4' + self.verify_ssl = True + self.is_master = False self.source = M3U8DownloaderUI(self.root) self.config_file = 'config.json' @@ -27,7 +29,7 @@ def tearDown(self): @pytest.mark.sequential_order def test__load_config_no_file(self): """Test if load config works as expected when configuration file do not exist""" - thread = DownloadThread(self.input_url, self.output_file, self.source) + thread = DownloadThread(self.input_url, self.output_file, self.verify_ssl, self.is_master, self.source) thread._load_config() self.assertFalse(os.path.exists(self.config_file)) self.assertFalse(thread.skip_space_check) @@ -43,7 +45,7 @@ def test__load_config_skip_space_check(self): with open(self.config_file, 'w') as file: file.write(json.dumps(config)) - thread = DownloadThread(self.input_url, self.output_file, self.source) + thread = DownloadThread(self.input_url, self.output_file, self.verify_ssl, self.is_master, self.source) thread._load_config() self.assertTrue(os.path.exists(self.config_file)) self.assertTrue(thread.skip_space_check) @@ -61,7 +63,7 @@ def test__load_config_debug(self): with open(self.config_file, 'w') as file: file.write(json.dumps(config)) - thread = DownloadThread(self.input_url, self.output_file, self.source) + thread = DownloadThread(self.input_url, self.output_file, self.verify_ssl, self.is_master, self.source) thread._load_config() self.assertTrue(os.path.exists(self.config_file)) self.assertFalse(thread.skip_space_check) @@ -77,7 +79,7 @@ def test__load_config_invalid(self): with open(self.config_file, 'w') as file: file.write(str(config)) - thread = DownloadThread(self.input_url, self.output_file, self.source) + thread = DownloadThread(self.input_url, self.output_file, self.verify_ssl, self.is_master, self.source) with self.assertRaises(json.JSONDecodeError): thread._load_config() finally: diff --git a/tests/unit/test_m3u8downloaderui.py b/tests/test_m3u8downloaderui.py similarity index 100% rename from tests/unit/test_m3u8downloaderui.py rename to tests/test_m3u8downloaderui.py diff --git a/tests/unit/test_stdoutredirector.py b/tests/test_stdoutredirector.py similarity index 100% rename from tests/unit/test_stdoutredirector.py rename to tests/test_stdoutredirector.py