From f546b43101f8ebf4346cf436286682d7b4c80560 Mon Sep 17 00:00:00 2001 From: Robert Honz Date: Tue, 1 Jul 2025 09:56:17 +0200 Subject: [PATCH 01/27] =?UTF-8?q?=E2=9C=A8=20Add=20docstrings=20for=20path?= =?UTF-8?q?-related=20functions=20and=20improve=20path=20sanitization=20lo?= =?UTF-8?q?gic.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tidal_dl_ng/helper/path.py | 191 +++++++++++++++++++++++-------------- 1 file changed, 118 insertions(+), 73 deletions(-) diff --git a/tidal_dl_ng/helper/path.py b/tidal_dl_ng/helper/path.py index 29347cb..61e2dae 100644 --- a/tidal_dl_ng/helper/path.py +++ b/tidal_dl_ng/helper/path.py @@ -24,6 +24,11 @@ from tidal_dl_ng.helper.tidal import name_builder_album_artist, name_builder_art def path_home() -> str: + """Get the home directory path. + + Returns: + str: The home directory path. + """ if "XDG_CONFIG_HOME" in os.environ: return os.environ["XDG_CONFIG_HOME"] elif "HOME" in os.environ: @@ -35,6 +40,11 @@ def path_home() -> str: def path_config_base() -> str: + """Get the base configuration path. + + Returns: + str: The base configuration path. + """ # https://wiki.archlinux.org/title/XDG_Base_Directory # X11 workaround: If user specified config path is set, do not point to "~/.config" path_user_custom: str = os.environ.get("XDG_CONFIG_HOME", "") @@ -45,14 +55,29 @@ def path_config_base() -> str: def path_file_log() -> str: + """Get the path to the log file. + + Returns: + str: The log file path. + """ return os.path.join(path_config_base(), "app.log") def path_file_token() -> str: + """Get the path to the token file. + + Returns: + str: The token file path. + """ return os.path.join(path_config_base(), "token.json") def path_file_settings() -> str: + """Get the path to the settings file. + + Returns: + str: The settings file path. + """ return os.path.join(path_config_base(), "settings.json") @@ -171,7 +196,7 @@ def format_str_media( result = media.video_quality case "track_quality": if isinstance(media, Track): - result = ", ".join(tag for tag in media.media_metadata_tags) + result = ", ".join(tag for tag in media.media_metadata_tags if tag is not None) case "track_explicit": if isinstance(media, Track | Video): result = FORMAT_TEMPLATE_EXPLICIT if media.explicit else "" @@ -203,18 +228,25 @@ def format_str_media( # TODO: Implement better exception logging. print(e) - pass - return result def calculate_number_padding(padding_minimum: int, item_position: int, items_max: int) -> str: + """Calculate the padded number string for an item. + + Args: + padding_minimum (int): Minimum number of digits for padding. + item_position (int): The position of the item. + items_max (int): The maximum number of items. + + Returns: + str: The padded number string. + """ result: str if items_max > 0: - count_digits: int = int(math.log10(items_max)) + 1 - count_digits_computed: int = count_digits if count_digits > padding_minimum else padding_minimum - result = str(item_position).zfill(count_digits_computed) + count_digits = max(int(math.log10(items_max)) + 1, padding_minimum) + result = str(item_position).zfill(count_digits) else: result = str(item_position) @@ -224,6 +256,15 @@ def calculate_number_padding(padding_minimum: int, item_position: int, items_max def get_format_template( media: Track | Album | Playlist | UserPlaylist | Video | Mix | MediaType, settings ) -> str | bool: + """Get the format template for a given media type. + + Args: + media (Track | Album | Playlist | UserPlaylist | Video | Mix | MediaType): The media object or type. + settings: The settings object containing format templates. + + Returns: + str | bool: The format template string or False if not found. + """ result = False if isinstance(media, Track) or media == MediaType.TRACK: @@ -241,83 +282,59 @@ def get_format_template( def path_file_sanitize(path_file: pathlib.Path, adapt: bool = False, uniquify: bool = False) -> pathlib.Path: - sanitized_filename: str = path_file.name - sanitized_path: pathlib.Path = path_file.parent - result: pathlib.Path + """Sanitize a file path to ensure it is valid and optionally make it unique. - # Sanitize filename and make sure it does not exceed FILENAME_LENGTH_MAX - try: - # sanitize_filename can shorten the file name actually - sanitized_filename = sanitize_filename( - sanitized_filename, replacement_text="_", validate_after_sanitize=True, platform="auto" + Args: + path_file (pathlib.Path): The file path to sanitize. + adapt (bool, optional): Whether to adapt the path in case of errors. Defaults to False. + uniquify (bool, optional): Whether to make the file name unique. Defaults to False. + + Returns: + pathlib.Path: The sanitized file path. + """ + sanitized_filename = sanitize_filename( + path_file.name, replacement_text="_", validate_after_sanitize=True, platform="auto" + ) + + if not sanitized_filename.endswith(path_file.suffix): + sanitized_filename = ( + sanitized_filename[: -len(path_file.suffix)] + FILENAME_SANITIZE_PLACEHOLDER + path_file.suffix ) - # Check if the file extension was removed by shortening the filename length - if not sanitized_filename.endswith(path_file.suffix): - # Add the original file extension - file_suffix: str = FILENAME_SANITIZE_PLACEHOLDER + path_file.suffix - sanitized_filename = sanitized_filename[: -len(file_suffix)] + file_suffix - except ValidationError as e: - if adapt: - # TODO: Implement proper exception handling and logging. - # Hacky stuff, since the sanitizing function does not shorten the filename (filename too long) - if str(e).startswith("[PV1101]"): - byte_ct: int = len(sanitized_filename.encode(sys.getfilesystemencoding())) - FILENAME_LENGTH_MAX - sanitized_filename = ( - sanitized_filename[: -byte_ct - len(FILENAME_SANITIZE_PLACEHOLDER) - len(path_file.suffix)] - + FILENAME_SANITIZE_PLACEHOLDER - + path_file.suffix - ) - else: - raise - else: - raise - - # Sanitize the path. - # First sanitize sanitize each part of the path. Each part of the path is not allowed to be longer then 'PC_NAME_MAX'. - sanitized_parts = [] - - for part in sanitized_path.parts: - if part in sanitized_path.anchor: - sanitized_parts.append(part) - else: - sanitized_parts.append( + sanitized_path = pathlib.Path( + *[ + ( sanitize_filename(part, replacement_text="_", validate_after_sanitize=True, platform="auto") + if part not in path_file.anchor + else part ) + for part in path_file.parent.parts + ] + ) - sanitized_path = pathlib.Path(*sanitized_parts) - - # Then sanitize the whole path itself. The whole path is not allowed to be longer than 'PC_NAME_MAX'. try: - sanitized_path: pathlib.Path = sanitize_filepath( + sanitized_path = sanitize_filepath( sanitized_path, replacement_text="_", validate_after_sanitize=True, platform="auto" ) except ValidationError as e: - # If adaption of path is allowed in case of an error set path to HOME. - if adapt: - if str(e).startswith("[PV1101]"): - sanitized_path = pathlib.Path.home() - else: - raise + if adapt and str(e).startswith("[PV1101]"): + sanitized_path = pathlib.Path.home() else: raise result = sanitized_path / sanitized_filename - # Uniquify - if uniquify: - result = path_file_uniquify(result) - - return result + return path_file_uniquify(result) if uniquify else result def path_file_uniquify(path_file: pathlib.Path) -> pathlib.Path: - """Checks whether the file exists, if so it tries to return an unique name suffix. + """Ensure a file path is unique by appending a suffix if necessary. - :param path_file: Path to file name which shall be unique. - :type path_file: pathlib.Path - :return: Unique file name with path for given input. - :rtype: pathlib.Path + Args: + path_file (pathlib.Path): The file path to uniquify. + + Returns: + pathlib.Path: The unique file path. """ unique_suffix: str = file_unique_suffix(path_file) @@ -334,6 +351,15 @@ def path_file_uniquify(path_file: pathlib.Path) -> pathlib.Path: def file_unique_suffix(path_file: pathlib.Path, seperator: str = "_") -> str: + """Generate a unique suffix for a file path. + + Args: + path_file (pathlib.Path): The file path to check for uniqueness. + seperator (str, optional): The separator to use for the suffix. Defaults to "_". + + Returns: + str: The unique suffix, or an empty string if not needed. + """ threshold_zfill: int = len(str(UNIQUIFY_THRESHOLD)) count: int = 0 path_file_tmp: pathlib.Path = deepcopy(path_file) @@ -348,6 +374,15 @@ def file_unique_suffix(path_file: pathlib.Path, seperator: str = "_") -> str: def check_file_exists(path_file: pathlib.Path, extension_ignore: bool = False) -> bool: + """Check if a file exists. + + Args: + path_file (pathlib.Path): The file path to check. + extension_ignore (bool, optional): Whether to ignore the file extension. Defaults to False. + + Returns: + bool: True if the file exists, False otherwise. + """ if extension_ignore: path_file_stem: str = pathlib.Path(path_file).stem path_parent: pathlib.Path = pathlib.Path(path_file).parent @@ -358,12 +393,20 @@ def check_file_exists(path_file: pathlib.Path, extension_ignore: bool = False) - else: path_files: [str] = [path_file] - result = bool(sum([[True] if os.path.isfile(_file) else [] for _file in path_files], [])) + result = any(os.path.isfile(_file) for _file in path_files) return result def resource_path(relative_path): + """Get the absolute path to a resource. + + Args: + relative_path (str): The relative path to the resource. + + Returns: + str: The absolute path to the resource. + """ try: base_path = sys._MEIPASS except Exception: @@ -373,14 +416,16 @@ def resource_path(relative_path): def url_to_filename(url: str) -> str: - """Return basename corresponding to url. - >>> print(url_to_filename('http://example.com/path/to/file%C3%80?opt=1')) - fileƀ - >>> print(url_to_filename('http://example.com/slash%2fname')) # '/' in name - Taken from https://gist.github.com/zed/c2168b9c52b032b5fb7d - Traceback (most recent call last): - ... - ValueError + """Convert a URL to a valid filename. + + Args: + url (str): The URL to convert. + + Returns: + str: The corresponding filename. + + Raises: + ValueError: If the URL contains invalid characters for a filename. """ urlpath: str = urlsplit(url).path basename: str = posixpath.basename(unquote(urlpath)) From eda5ab92c010822f638047a657bad6b8eaba3e41 Mon Sep 17 00:00:00 2001 From: Robert Honz Date: Tue, 1 Jul 2025 10:08:46 +0200 Subject: [PATCH 02/27] =?UTF-8?q?feat:=20=E2=9C=A8=20Add=20project=20codin?= =?UTF-8?q?g=20standards=20for=20Python?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Introduced guidelines for naming conventions and error handling. * Specified the use of `snake_case` for variables, `CamelCase` for classes, and PEP 8 compliance. * Emphasized the importance of type hints and docstrings in code documentation. --- .github/copilot-instructions.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 .github/copilot-instructions.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..94aae70 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,20 @@ +--- +applyTo: "**/*.py" +--- + +# Project general coding standards + +## Naming Conventions + +- Use snake_case for variable and function names. +- Use CamelCase for class names. +- Follow PEP 8 style guidelines. +- Include type hints for function parameters and return types. +- Write docstrings for all public modules, classes, functions, and methods using google docsrting style. +- Prefix private class members with underscore (\_) +- Use ALL_CAPS for constants + +## Error Handling + +- Use try/except blocks for async operations +- Always log errors with contextual information From 6395a00ed611ccddf96169a5ca4e8904268e4e1b Mon Sep 17 00:00:00 2001 From: Robert Honz Date: Tue, 1 Jul 2025 10:17:56 +0200 Subject: [PATCH 03/27] =?UTF-8?q?chore:=20=F0=9F=94=A7=20Update=20Python?= =?UTF-8?q?=20version=20constraints=20in=20configuration=20files?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Adjusted `requires-python` in `pyproject.toml` to `>=3.12,<3.13` * Modified `envlist` in `tox.ini` to remove Python 3.13 * Updated `python-versions` in `poetry.lock` to reflect the new constraints * coloredlogs does not support py3.13, yet, see https://github.com/xolox/python-coloredlogs/issues/119 --- poetry.lock | 4 ++-- pyproject.toml | 5 ++--- tox.ini | 3 +-- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/poetry.lock b/poetry.lock index c136058..0c8e9b1 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2337,5 +2337,5 @@ gui = ["pyqtdarktheme-fork", "pyside6"] [metadata] lock-version = "2.1" -python-versions = ">=3.12,<3.14" -content-hash = "ad08ac2eff30520097a409a3461facd636bbee0fc4228fa2131c53f4684bb5c6" +python-versions = ">=3.12,<3.13" +content-hash = "316194a2b8f04f23039eeeac048da8e0ca23417799ae31a46713a5157bb703c6" diff --git a/pyproject.toml b/pyproject.toml index d634429..700c9fc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,9 +10,8 @@ classifiers = [ "License :: OSI Approved :: GNU Affero General Public License v3", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.12", - "Programming Language :: Python :: 3.13", ] -requires-python = ">=3.12,<3.14" +requires-python = ">=3.12,<3.13" dynamic = ["dependencies"] [project.urls] @@ -141,7 +140,7 @@ ignore = [ [tool.black] # https://github.com/psf/black -target-version = ["py312", "py313"] +target-version = ["py312"] line-length = 120 color = true preview = true diff --git a/tox.ini b/tox.ini index e156596..7dffa3f 100644 --- a/tox.ini +++ b/tox.ini @@ -1,11 +1,10 @@ [tox] skipsdist = true -envlist = py312,py313 +envlist = py312 [gh-actions] python = 3.12: py312 - 3.13: py313 [testenv] passenv = PYTHON_VERSION From ca927984d350328323b7d907067119cc2f19baf6 Mon Sep 17 00:00:00 2001 From: Robert Honz Date: Tue, 1 Jul 2025 10:34:20 +0200 Subject: [PATCH 04/27] =?UTF-8?q?chore:=20=F0=9F=94=A7=20Update=20docstrin?= =?UTF-8?q?g=20guidelines=20in=20coding=20standards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Changed "Write docstrings for all public modules, classes, functions, and methods" to "Always write doc strings for all modules, classes, functions, and methods." * Added missing punctuation for consistency in error handling guidelines. --- .github/copilot-instructions.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 94aae70..0c3735c 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -10,11 +10,11 @@ applyTo: "**/*.py" - Use CamelCase for class names. - Follow PEP 8 style guidelines. - Include type hints for function parameters and return types. -- Write docstrings for all public modules, classes, functions, and methods using google docsrting style. -- Prefix private class members with underscore (\_) -- Use ALL_CAPS for constants +- Always write doc strings for all modules, classes, functions, and methods using google docstring style. +- Prefix private class members with underscore (\_). +- Use ALL_CAPS for constants. ## Error Handling -- Use try/except blocks for async operations -- Always log errors with contextual information +- Use try/except blocks for async operations. +- Always log errors with contextual information. From de47935f049cdab38cbe5ccd665176761ec2c632 Mon Sep 17 00:00:00 2001 From: Robert Honz Date: Tue, 1 Jul 2025 10:46:41 +0200 Subject: [PATCH 05/27] =?UTF-8?q?chore:=20=F0=9F=94=A7=20Update=20coding?= =?UTF-8?q?=20standards=20for=20Python?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added a guideline to use the newest coding style supported by the used Python version. --- .github/copilot-instructions.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 0c3735c..0604a99 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -13,6 +13,7 @@ applyTo: "**/*.py" - Always write doc strings for all modules, classes, functions, and methods using google docstring style. - Prefix private class members with underscore (\_). - Use ALL_CAPS for constants. +- Use newest coding style which is supported by the used Python version. ## Error Handling From 1f7d8a15e2a9480976b3a1bc2af84aa939bd2c27 Mon Sep 17 00:00:00 2001 From: Robert Honz Date: Tue, 1 Jul 2025 11:04:58 +0200 Subject: [PATCH 06/27] =?UTF-8?q?chore:=20=F0=9F=94=A7=20Update=20docstrin?= =?UTF-8?q?g=20guidelines=20for=20typing=20usage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added clarification to the docstring guidelines to include the use of `typing` in docstrings. --- .github/copilot-instructions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 0604a99..f10cd46 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -10,7 +10,7 @@ applyTo: "**/*.py" - Use CamelCase for class names. - Follow PEP 8 style guidelines. - Include type hints for function parameters and return types. -- Always write doc strings for all modules, classes, functions, and methods using google docstring style. +- Always write doc strings for all modules, classes, functions, and methods using google docstring style. Use typing in doc strings. - Prefix private class members with underscore (\_). - Use ALL_CAPS for constants. - Use newest coding style which is supported by the used Python version. From a91a368994314a2d3938c851a13b4b99cbef2359 Mon Sep 17 00:00:00 2001 From: Robert Honz Date: Tue, 1 Jul 2025 11:05:33 +0200 Subject: [PATCH 07/27] =?UTF-8?q?feat:=20=E2=9C=A8=20Enhance=20media=20for?= =?UTF-8?q?matting=20functions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Added detailed docstrings for `format_str_media` and related helper functions. * Refactored media formatting logic to utilize dedicated functions for better readability and maintainability. * Improved error handling in media formatting. * Updated `file_unique_suffix` function to correct the spelling of `separator`. --- tidal_dl_ng/helper/path.py | 404 ++++++++++++++++++++++++++----------- 1 file changed, 281 insertions(+), 123 deletions(-) diff --git a/tidal_dl_ng/helper/path.py b/tidal_dl_ng/helper/path.py index 61e2dae..12ffdce 100644 --- a/tidal_dl_ng/helper/path.py +++ b/tidal_dl_ng/helper/path.py @@ -116,119 +116,282 @@ def format_str_media( list_pos: int = 0, list_total: int = 0, ) -> str: - result: str = name + """Format a string based on media attributes. + Args: + name (str): The format template name. + media (Track | Album | Playlist | UserPlaylist | Video | Mix): The media object. + album_track_num_pad_min (int): Minimum padding for track numbers. Defaults to 0. + list_pos (int): Position in a list. Defaults to 0. + list_total (int): Total items in a list. Defaults to 0. + + Returns: + str: The formatted string. + """ try: - match name: - case "artist_name": - if isinstance(media, Track | Video): - if hasattr(media, "artists"): - result = name_builder_artist(media) - elif hasattr(media, "artist"): - result = media.artist.name - case "album_artist": - result = name_builder_album_artist(media, first_only=True) - case "album_artists": - result = name_builder_album_artist(media) - case "track_title": - if isinstance(media, Track | Video): - result = name_builder_title(media) - case "mix_name": - if isinstance(media, Mix): - result = media.title - case "playlist_name": - if isinstance(media, Playlist | UserPlaylist): - result = media.name - case "album_title": - if isinstance(media, Album): - result = media.name - elif isinstance(media, Track): - result = media.album.name - case "album_track_num": - if isinstance(media, Track | Video): - result = calculate_number_padding( - album_track_num_pad_min, - media.track_num, - media.album.num_tracks if hasattr(media, "album") else 1, - ) - case "album_num_tracks": - if isinstance(media, Track | Video): - result = str(media.album.num_tracks if hasattr(media, "album") else 1) - case "track_id": - if isinstance(media, Track | Video): - result = str(media.id) - case "playlist_id": - if isinstance(media, Playlist): - result = str(media.id) - case "album_id": - if isinstance(media, Album): - result = str(media.id) - elif isinstance(media, Track): - result = str(media.album.id) - case "track_duration_seconds": - if isinstance(media, Track | Video): - result = str(media.duration) - case "track_duration_minutes": - if isinstance(media, Track | Video): - m, s = divmod(media.duration, 60) - result = f"{m:01d}:{s:02d}" - case "album_duration_seconds": - if isinstance(media, Album): - result = str(media.duration) - case "album_duration_minutes": - if isinstance(media, Album): - m, s = divmod(media.duration, 60) - result = f"{m:01d}:{s:02d}" - case "playlist_duration_seconds": - if isinstance(media, Album): - result = str(media.duration) - case "playlist_duration_minutes": - if isinstance(media, Album): - m, s = divmod(media.duration, 60) - result = f"{m:01d}:{s:02d}" - case "album_year": - if isinstance(media, Album): - result = str(media.year) - elif isinstance(media, Track): - result = str(media.album.year) - case "video_quality": - if isinstance(media, Video): - result = media.video_quality - case "track_quality": - if isinstance(media, Track): - result = ", ".join(tag for tag in media.media_metadata_tags if tag is not None) - case "track_explicit": - if isinstance(media, Track | Video): - result = FORMAT_TEMPLATE_EXPLICIT if media.explicit else "" - case "album_explicit": - if isinstance(media, Album): - result = FORMAT_TEMPLATE_EXPLICIT if media.explicit else "" - case "album_num_volumes": - if isinstance(media, Album): - result = str(media.num_volumes) - case "track_volume_num": - if isinstance(media, Track | Video): - result = str(media.volume_num) - case "track_volume_num_optional": - if isinstance(media, Track | Video): - num_volumes: int = media.album.num_volumes if hasattr(media, "album") else 1 - result = "" if num_volumes == 1 else str(media.volume_num) - case "track_volume_num_optional_CD": - if isinstance(media, Track | Video): - num_volumes: int = media.album.num_volumes if hasattr(media, "album") else 1 - result = "" if num_volumes == 1 else f"CD{media.volume_num!s}" - case "isrc": - if isinstance(media, Track): - result = media.isrc - case "list_pos": - if isinstance(media, Track | Video): - # TODO: Rename `album_track_num_pad_min` globally. - result = calculate_number_padding(album_track_num_pad_min, list_pos, list_total) + # Try each formatter function in sequence + for formatter in ( + _format_names, + _format_numbers, + _format_ids, + _format_durations, + _format_metadata, + _format_volumes, + ): + result = formatter(name, media, album_track_num_pad_min, list_pos, list_total) + if result is not None: + return result except Exception as e: # TODO: Implement better exception logging. print(e) - return result + return name + + +def _format_artist_names( + name: str, + media: Track | Album | Playlist | UserPlaylist | Video | Mix, + *_args, +) -> str | None: + """Handle artist name-related format strings. + + Args: + name (str): The format string name to check. + media (Track | Album | Playlist | UserPlaylist | Video | Mix): The media object to extract artist information from. + *_args (Any): Additional arguments (not used). + + Returns: + str | None: The formatted artist name or None if the format string is not artist-related. + """ + if name == "artist_name" and isinstance(media, Track | Video): + if hasattr(media, "artists"): + return name_builder_artist(media) + elif hasattr(media, "artist"): + return media.artist.name + elif name == "album_artist": + return name_builder_album_artist(media, first_only=True) + elif name == "album_artists": + return name_builder_album_artist(media) + return None + + +def _format_titles( + name: str, + media: Track | Album | Playlist | UserPlaylist | Video | Mix, + *_args, +) -> str | None: + """Handle title-related format strings. + + Args: + name (str): The format string name to check. + media (Track | Album | Playlist | UserPlaylist | Video | Mix): The media object to extract title information from. + *_args (Any): Additional arguments (not used). + + Returns: + str | None: The formatted title or None if the format string is not title-related. + """ + if name == "track_title" and isinstance(media, Track | Video): + return name_builder_title(media) + elif name == "mix_name" and isinstance(media, Mix): + return media.title + elif name == "playlist_name" and isinstance(media, Playlist | UserPlaylist): + return media.name + elif name == "album_title": + if isinstance(media, Album): + return media.name + elif isinstance(media, Track): + return media.album.name + return None + + +def _format_names( + name: str, + media: Track | Album | Playlist | UserPlaylist | Video | Mix, + *_args, +) -> str | None: + """Handle name-related format strings. + + Args: + name (str): The format string name to check. + media (Track | Album | Playlist | UserPlaylist | Video | Mix): The media object to extract name information from. + *_args (Any): Additional arguments (not used). + + Returns: + str | None: The formatted name or None if the format string is not name-related. + """ + # First try artist name formats + result = _format_artist_names(name, media) + if result is not None: + return result + + # Then try title formats + return _format_titles(name, media) + + +def _format_numbers( + name: str, + media: Track | Album | Playlist | UserPlaylist | Video | Mix, + album_track_num_pad_min: int, + list_pos: int, + list_total: int, + *_args, +) -> str | None: + """Handle number-related format strings. + + Args: + name (str): The format string name to check. + media (Track | Album | Playlist | UserPlaylist | Video | Mix): The media object to extract number information from. + album_track_num_pad_min (int): Minimum padding for track numbers. + list_pos (int): Position in a list. + list_total (int): Total items in a list. + *_args (Any): Additional arguments (not used). + + Returns: + str | None: The formatted number or None if the format string is not number-related. + """ + if name == "album_track_num" and isinstance(media, Track | Video): + return calculate_number_padding( + album_track_num_pad_min, + media.track_num, + media.album.num_tracks if hasattr(media, "album") else 1, + ) + elif name == "album_num_tracks" and isinstance(media, Track | Video): + return str(media.album.num_tracks if hasattr(media, "album") else 1) + elif name == "list_pos" and isinstance(media, Track | Video): + # TODO: Rename `album_track_num_pad_min` globally. + return calculate_number_padding(album_track_num_pad_min, list_pos, list_total) + return None + + +def _format_ids( + name: str, + media: Track | Album | Playlist | UserPlaylist | Video | Mix, + *_args, +) -> str | None: + """Handle ID-related format strings. + + Args: + name (str): The format string name to check. + media (Track | Album | Playlist | UserPlaylist | Video | Mix): The media object to extract ID information from. + *_args (Any): Additional arguments (not used). + + Returns: + str | None: The formatted ID or None if the format string is not ID-related. + """ + # Handle track and playlist IDs + if (name == "track_id" and isinstance(media, Track | Video)) or ( + name == "playlist_id" and isinstance(media, Playlist) + ): + return str(media.id) + # Handle album IDs + elif name == "album_id": + if isinstance(media, Album): + return str(media.id) + elif isinstance(media, Track): + return str(media.album.id) + # Handle ISRC + elif name == "isrc" and isinstance(media, Track): + return media.isrc + return None + + +def _format_durations( + name: str, + media: Track | Album | Playlist | UserPlaylist | Video | Mix, + *_args, +) -> str | None: + """Handle duration-related format strings. + + Args: + name (str): The format string name to check. + media (Track | Album | Playlist | UserPlaylist | Video | Mix): The media object to extract duration information from. + *_args (Any): Additional arguments (not used). + + Returns: + str | None: The formatted duration or None if the format string is not duration-related. + """ + # Format track durations + if name == "track_duration_seconds" and isinstance(media, Track | Video): + return str(media.duration) + elif name == "track_duration_minutes" and isinstance(media, Track | Video): + m, s = divmod(media.duration, 60) + return f"{m:01d}:{s:02d}" + + # Format album durations + elif name == "album_duration_seconds" and isinstance(media, Album): + return str(media.duration) + elif name == "album_duration_minutes" and isinstance(media, Album): + m, s = divmod(media.duration, 60) + return f"{m:01d}:{s:02d}" + + # Format playlist durations + elif name == "playlist_duration_seconds" and isinstance(media, Album): + return str(media.duration) + elif name == "playlist_duration_minutes" and isinstance(media, Album): + m, s = divmod(media.duration, 60) + return f"{m:01d}:{s:02d}" + + # Format year + elif name == "album_year": + if isinstance(media, Album): + return str(media.year) + elif isinstance(media, Track): + return str(media.album.year) + return None + + +def _format_metadata( + name: str, + media: Track | Album | Playlist | UserPlaylist | Video | Mix, + *_args, +) -> str | None: + """Handle metadata-related format strings. + + Args: + name (str): The format string name to check. + media (Track | Album | Playlist | UserPlaylist | Video | Mix): The media object to extract metadata information from. + *_args (Any): Additional arguments (not used). + + Returns: + str | None: The formatted metadata or None if the format string is not metadata-related. + """ + if name == "video_quality" and isinstance(media, Video): + return media.video_quality + elif name == "track_quality" and isinstance(media, Track): + return ", ".join(tag for tag in media.media_metadata_tags if tag is not None) + elif (name == "track_explicit" and isinstance(media, Track | Video)) or ( + name == "album_explicit" and isinstance(media, Album) + ): + return FORMAT_TEMPLATE_EXPLICIT if media.explicit else "" + return None + + +def _format_volumes( + name: str, + media: Track | Album | Playlist | UserPlaylist | Video | Mix, + *_args, +) -> str | None: + """Handle volume-related format strings. + + Args: + name (str): The format string name to check. + media (Track | Album | Playlist | UserPlaylist | Video | Mix): The media object to extract volume information from. + *_args (Any): Additional arguments (not used). + + Returns: + str | None: The formatted volume information or None if the format string is not volume-related. + """ + if name == "album_num_volumes" and isinstance(media, Album): + return str(media.num_volumes) + elif name == "track_volume_num" and isinstance(media, Track | Video): + return str(media.volume_num) + elif name == "track_volume_num_optional" and isinstance(media, Track | Video): + num_volumes: int = media.album.num_volumes if hasattr(media, "album") else 1 + return "" if num_volumes == 1 else str(media.volume_num) + elif name == "track_volume_num_optional_CD" and isinstance(media, Track | Video): + num_volumes: int = media.album.num_volumes if hasattr(media, "album") else 1 + return "" if num_volumes == 1 else f"CD{media.volume_num!s}" + return None def calculate_number_padding(padding_minimum: int, item_position: int, items_max: int) -> str: @@ -350,12 +513,12 @@ def path_file_uniquify(path_file: pathlib.Path) -> pathlib.Path: return path_file -def file_unique_suffix(path_file: pathlib.Path, seperator: str = "_") -> str: +def file_unique_suffix(path_file: pathlib.Path, separator: str = "_") -> str: """Generate a unique suffix for a file path. Args: path_file (pathlib.Path): The file path to check for uniqueness. - seperator (str, optional): The separator to use for the suffix. Defaults to "_". + separator (str, optional): The separator to use for the suffix. Defaults to "_". Returns: str: The unique suffix, or an empty string if not needed. @@ -367,7 +530,7 @@ def file_unique_suffix(path_file: pathlib.Path, seperator: str = "_") -> str: while check_file_exists(path_file_tmp) and count < UNIQUIFY_THRESHOLD: count += 1 - unique_suffix = seperator + str(count).zfill(threshold_zfill) + unique_suffix = separator + str(count).zfill(threshold_zfill) path_file_tmp = path_file.parent / (path_file.stem + unique_suffix + path_file.suffix) return unique_suffix @@ -386,31 +549,26 @@ def check_file_exists(path_file: pathlib.Path, extension_ignore: bool = False) - if extension_ignore: path_file_stem: str = pathlib.Path(path_file).stem path_parent: pathlib.Path = pathlib.Path(path_file).parent - path_files: [str] = [] + path_files: list[str] = [] - for extension in AudioExtensions: - path_files.append(str(path_parent.joinpath(path_file_stem + extension))) + path_files.extend(str(path_parent.joinpath(path_file_stem + extension)) for extension in AudioExtensions) else: - path_files: [str] = [path_file] + path_files: list[str] = [str(path_file)] - result = any(os.path.isfile(_file) for _file in path_files) - - return result + return any(os.path.isfile(_file) for _file in path_files) -def resource_path(relative_path): +def resource_path(relative_path: str) -> str: """Get the absolute path to a resource. Args: - relative_path (str): The relative path to the resource. + relative_path: The relative path to the resource. Returns: str: The absolute path to the resource. """ - try: - base_path = sys._MEIPASS - except Exception: - base_path = os.path.abspath(".") + # PyInstaller creates a temp folder and stores path in _MEIPASS + base_path = getattr(sys, "_MEIPASS", os.path.abspath(".")) return os.path.join(base_path, relative_path) From 29ffd5df4b65da9d206f31db974b7c95dffeda0d Mon Sep 17 00:00:00 2001 From: Robert Honz Date: Tue, 1 Jul 2025 11:09:27 +0200 Subject: [PATCH 08/27] =?UTF-8?q?chore:=20=F0=9F=94=A7=20Update=20link=20f?= =?UTF-8?q?ormat=20in=20README?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Changed the link to `cookiecutter-poetry` for consistency. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index d3f0a10..c077c70 100644 --- a/README.md +++ b/README.md @@ -191,4 +191,4 @@ Special thanks goes out to [@orbittwz](https://github.com/orbittwz) for all his This project is based on: -- +- [cookiecutter-poetry](https://fpgmaas.github.io/cookiecutter-poetry/) From 4dde8c819a82a763e8b5df8a843559a2aafd3e4b Mon Sep 17 00:00:00 2001 From: Robert Honz Date: Tue, 1 Jul 2025 11:33:04 +0200 Subject: [PATCH 09/27] =?UTF-8?q?chore:=20=F0=9F=94=A7=20Update=20coding?= =?UTF-8?q?=20standards=20and=20documentation=20guidelines?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Refined coding guidelines for clarity and structure. * Added sections for type annotations and docstring requirements. * Ensured consistency in documentation practices across the project. --- .github/copilot-instructions.md | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index f10cd46..472850f 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -9,13 +9,21 @@ applyTo: "**/*.py" - Use snake_case for variable and function names. - Use CamelCase for class names. - Follow PEP 8 style guidelines. -- Include type hints for function parameters and return types. -- Always write doc strings for all modules, classes, functions, and methods using google docstring style. Use typing in doc strings. - Prefix private class members with underscore (\_). - Use ALL_CAPS for constants. -- Use newest coding style which is supported by the used Python version. ## Error Handling - Use try/except blocks for async operations. - Always log errors with contextual information. + +## Coding Guidelines + +- Use type annotations for function parameters, return types and variables. +- Use newest coding style which is supported by the used Python version. + +## Code Documentation + +- Always write doc strings for all modules, classes, functions, and methods using google docstring style. +- Use typing in doc strings. +- Use short inline comments to explain complex logic. From 38a97357dd76b4ce4a485d72c0f83652550f8259 Mon Sep 17 00:00:00 2001 From: Robert Honz Date: Tue, 1 Jul 2025 11:42:07 +0200 Subject: [PATCH 10/27] =?UTF-8?q?refactor(cli):=20=E2=9C=A8=20Improve=20fu?= =?UTF-8?q?nction=20documentation=20and=20remove=20unused=20imports?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Enhanced docstrings for clarity and consistency across functions. * Removed unused imports to clean up the codebase. * Improved the structure of the `_download` function for better readability. --- tidal_dl_ng/cli.py | 326 +++++++++++++++++++++++++++++---------------- 1 file changed, 211 insertions(+), 115 deletions(-) diff --git a/tidal_dl_ng/cli.py b/tidal_dl_ng/cli.py index 75deba4..966ed88 100644 --- a/tidal_dl_ng/cli.py +++ b/tidal_dl_ng/cli.py @@ -22,12 +22,6 @@ from tidal_dl_ng.constants import CTX_TIDAL, MediaType from tidal_dl_ng.download import Download from tidal_dl_ng.helper.path import get_format_template, path_file_settings from tidal_dl_ng.helper.tidal import ( - Album, - Artist, - Mix, - Playlist, - Track, - Video, all_artist_album_ids, get_tidal_media_id, get_tidal_media_type, @@ -47,30 +41,155 @@ app.add_typer(dl_fav_group, name="dl_fav") def version_callback(value: bool): + """Callback to print version and exit if version flag is set. + + Args: + value (bool): If True, prints version and exits. + """ if value: print(f"{__version__}") raise typer.Exit() +def _handle_track_or_video( + dl: Download, settings: Settings, item: str, media: object, file_template: str, idx: int, urls_pos_last: int +) -> None: + """Handle downloading a track or video item. + + Args: + dl (Download): The Download instance. + settings (Settings): The Settings instance. + item (str): The URL or identifier of the item. + media: The media object to download. + file_template (str): The file template for saving the media. + idx (int): The index of the item in the list. + urls_pos_last (int): The last index in the URLs list. + """ + download_delay: bool = bool(settings.data.download_delay and idx < urls_pos_last) + + dl.item( + media=media, + file_template=file_template, + download_delay=download_delay, + quality_audio=settings.data.quality_audio, + quality_video=settings.data.quality_video, + ) + + +def _handle_album_playlist_mix_artist( + dl: Download, + ctx: typer.Context, + handling_app: HandlingApp, + settings: Settings, + media_type: MediaType, + media: object, + item_id: int, + file_template: str, +) -> bool: + """Handle downloading albums, playlists, mixes, or artist collections. + + Args: + dl (Download): The Download instance. + ctx (typer.Context): Typer context object. + handling_app (HandlingApp): The HandlingApp instance. + settings (Settings): The Settings instance. + media_type (MediaType): The type of media (album, playlist, mix, or artist). + media: The media object to download. + item_id (int): The ID of the media item. + file_template (str): The file template for saving the media. + + Returns: + bool: False if aborted, True otherwise. + """ + item_ids: list[int] = [] + + if media_type == MediaType.ARTIST: + media_type = MediaType.ALBUM + item_ids = item_ids + all_artist_album_ids(media) + else: + item_ids.append(item_id) + + for _item_id in item_ids: + if handling_app.event_abort.is_set(): + return False + + dl.items( + media_id=_item_id, + media_type=media_type, + file_template=file_template, + video_download=ctx.obj[CTX_TIDAL].settings.data.video_download, + download_delay=settings.data.download_delay, + ) + + return True + + +def _process_url( + dl: Download, + ctx: typer.Context, + handling_app: HandlingApp, + settings: Settings, + item: str, + idx: int, + urls_pos_last: int, +) -> bool: + """Process a single URL or ID for download. + + Args: + dl (Download): The Download instance. + ctx (typer.Context): Typer context object. + handling_app (HandlingApp): The HandlingApp instance. + settings (Settings): The Settings instance. + item (str): The URL or identifier to process. + idx (int): The index of the item in the list. + urls_pos_last (int): The last index in the URLs list. + + Returns: + bool: False if aborted, True otherwise. + """ + if handling_app.event_abort.is_set(): + return False + + if "http" not in item: + print(f"It seems like that you have supplied an invalid URL: {item}") + return True + + media_type: MediaType = get_tidal_media_type(item) + item_id: int = get_tidal_media_id(item) + file_template: str = get_format_template(media_type, settings) + + try: + media: object = instantiate_media(ctx.obj[CTX_TIDAL].session, media_type, item_id) + except Exception: + print(f"Media not found (ID: {item_id}). Maybe it is not available anymore.") + return True + + if media_type in [MediaType.TRACK, MediaType.VIDEO]: + _handle_track_or_video(dl, settings, item, media, file_template, idx, urls_pos_last) + elif media_type in [MediaType.ALBUM, MediaType.PLAYLIST, MediaType.MIX, MediaType.ARTIST]: + return _handle_album_playlist_mix_artist( + dl, ctx, handling_app, settings, media_type, media, item_id, file_template + ) + return True + + def _download(ctx: typer.Context, urls: list[str], try_login: bool = True) -> bool: """Invokes download function and tracks progress. - :param ctx: The typer context object. - :type ctx: typer.Context - :param urls: The list of URLs to download. - :type urls: list[str] - :param try_login: If true, attempts to login to TIDAL. - :type try_login: bool - :return: True if ran successfully. - :rtype: bool + Args: + ctx (typer.Context): The typer context object. + urls (list[str]): The list of URLs to download. + try_login (bool, optional): If true, attempts to login to TIDAL. Defaults to True. + + Returns: + bool: True if ran successfully. """ if try_login: - # Call login method to validate the token. ctx.invoke(login, ctx) - # Create initial objects. settings: Settings = Settings() handling_app: HandlingApp = HandlingApp() + progress: Progress = Progress( TextColumn("[progress.description]{task.description}"), SpinnerColumn(), @@ -79,8 +198,9 @@ def _download(ctx: typer.Context, urls: list[str], try_login: bool = True) -> bo refresh_per_second=20, auto_refresh=True, expand=True, - transient=False, # Prevent progress from disappearing + transient=False, ) + progress_overall = Progress( TextColumn("[progress.description]{task.description}"), SpinnerColumn(), @@ -89,9 +209,11 @@ def _download(ctx: typer.Context, urls: list[str], try_login: bool = True) -> bo refresh_per_second=20, auto_refresh=True, expand=True, - transient=False, # Prevent progress from disappearing + transient=False, ) + fn_logger = LoggerWrapped(progress.print) + dl = Download( session=ctx.obj[CTX_TIDAL].session, skip_existing=ctx.obj[CTX_TIDAL].settings.data.skip_existing, @@ -102,80 +224,20 @@ def _download(ctx: typer.Context, urls: list[str], try_login: bool = True) -> bo event_abort=handling_app.event_abort, event_run=handling_app.event_run, ) - progress_table = Table.grid() - # Style Progress display. + progress_table = Table.grid() progress_table.add_row(progress) progress_table.add_row(progress_overall) - - progress_group = Group( - progress_table, - ) + progress_group = Group(progress_table) urls_pos_last = len(urls) - 1 - # Use a single Live display for both progress and table with Live(progress_group, refresh_per_second=20, vertical_overflow="visible"): try: - for item in urls: - media_type: MediaType | bool = False - - # Exit loop if abort signal is set. - if handling_app.event_abort.is_set(): + for idx, item in enumerate(urls): + if _process_url(dl, ctx, handling_app, settings, item, idx, urls_pos_last) is False: return False - - # Extract media name and id from link. - if "http" in item: - media_type = get_tidal_media_type(item) - item_id = get_tidal_media_id(item) - file_template = get_format_template(media_type, settings) - else: - print(f"It seems like that you have supplied an invalid URL: {item}") - - continue - - try: - media: Track | Video | Album | Playlist | Mix | Artist = instantiate_media( - ctx.obj[CTX_TIDAL].session, media_type, item_id - ) - except Exception: - print(f"Media not found (ID: {item_id}). Maybe it is not available anymore.") - continue - - # Download media. - if media_type in [MediaType.TRACK, MediaType.VIDEO]: - download_delay: bool = bool(settings.data.download_delay and urls.index(item) < urls_pos_last) - - dl.item( - media=media, - file_template=file_template, - download_delay=download_delay, - quality_audio=settings.data.quality_audio, - quality_video=settings.data.quality_video, - ) - elif media_type in [MediaType.ALBUM, MediaType.PLAYLIST, MediaType.MIX, MediaType.ARTIST]: - item_ids: [int] = [] - - if media_type == MediaType.ARTIST: - media_type = MediaType.ALBUM - item_ids = item_ids + all_artist_album_ids(media) - else: - item_ids.append(item_id) - - for item_id in item_ids: - # Exit loop if abort signal is set. - if handling_app.event_abort.is_set(): - return False - - dl.items( - media_id=item_id, - media_type=media_type, - file_template=file_template, - video_download=ctx.obj[CTX_TIDAL].settings.data.video_download, - download_delay=settings.data.download_delay, - ) finally: - # Clear and stop progress display progress.refresh() progress.stop() @@ -187,6 +249,12 @@ def callback_app( ctx: typer.Context, version: Annotated[bool | None, typer.Option("--version", "-v", callback=version_callback, is_eager=True)] = None, ): + """App callback to initialize context and handle version option. + + Args: + ctx (typer.Context): Typer context object. + version (bool | None, optional): Version flag. Defaults to None. + """ ctx.obj = {"tidal": None} @@ -197,15 +265,11 @@ def settings_management( bool, typer.Option("--editor", "-e", help="Open the settings file in your default editor.") ] = False, ): - """ - Print or set an option. - If no arguments are given, all options will be listed. - If only one argument is given, the value will be printed for this option. - To set a value for an option simply pass the value as the second argument + """Print or set an option, or open the settings file in an editor. - :param editor: If set, your favorite system editor will be opened. - :param names: (Optional) None (list all options), one (list the value only for this option) or two arguments - (set the value for the option). + Args: + names (list[str] | None, optional): None (list all options), one (list the value only for this option) or two arguments (set the value for the option). Defaults to None. + editor (bool, optional): If set, your favorite system editor will be opened. Defaults to False. """ if editor: config_path: Path = Path(path_file_settings()) @@ -246,6 +310,14 @@ def settings_management( @app.command(name="login") def login(ctx: typer.Context) -> bool: + """Login to TIDAL and update context object. + + Args: + ctx (typer.Context): Typer context object. + + Returns: + bool: True if login was successful, False otherwise. + """ print("Let us check, if you are already logged in... ", end="") settings = Settings() @@ -258,6 +330,11 @@ def login(ctx: typer.Context) -> bool: @app.command(name="logout") def logout() -> bool: + """Logout from TIDAL. + + Returns: + bool: True if logout was successful, False otherwise. + """ settings = Settings() tidal = Tidal(settings) result = tidal.logout() @@ -287,6 +364,16 @@ def download( ), ] = None, ) -> bool: + """Download media from provided URLs or a file containing URLs. + + Args: + ctx (typer.Context): Typer context object. + urls (list[str] | None, optional): List of URLs to download. Defaults to None. + file_urls (Path | None, optional): Path to file containing URLs. Defaults to None. + + Returns: + bool: True if download was successful, False otherwise. + """ if not urls: # Read the text file provided. if file_urls: @@ -307,10 +394,11 @@ def download( def download_fav_tracks(ctx: typer.Context) -> bool: """Download your favorite track collection. - :param ctx: Typer context object. - :type ctx: typer.Context - :return: Download result. - :rtype: bool + Args: + ctx (typer.Context): Typer context object. + + Returns: + bool: Download result. """ # Method name func_name_favorites: str = "tracks" @@ -325,10 +413,11 @@ def download_fav_tracks(ctx: typer.Context) -> bool: def download_fav_artists(ctx: typer.Context) -> bool: """Download your favorite artist collection. - :param ctx: Typer context object. - :type ctx: typer.Context - :return: Download result. - :rtype: bool + Args: + ctx (typer.Context): Typer context object. + + Returns: + bool: Download result. """ # Method name func_name_favorites: str = "artists" @@ -343,10 +432,11 @@ def download_fav_artists(ctx: typer.Context) -> bool: def download_fav_albums(ctx: typer.Context) -> bool: """Download your favorite album collection. - :param ctx: Typer context object. - :type ctx: typer.Context - :return: Download result. - :rtype: bool + Args: + ctx (typer.Context): Typer context object. + + Returns: + bool: Download result. """ # Method name func_name_favorites: str = "albums" @@ -361,10 +451,11 @@ def download_fav_albums(ctx: typer.Context) -> bool: def download_fav_videos(ctx: typer.Context) -> bool: """Download your favorite video collection. - :param ctx: Typer context object. - :type ctx: typer.Context - :return: Download result. - :rtype: bool + Args: + ctx (typer.Context): Typer context object. + + Returns: + bool: Download result. """ # Method name func_name_favorites: str = "videos" @@ -375,12 +466,12 @@ def download_fav_videos(ctx: typer.Context) -> bool: def _download_fav_factory(ctx: typer.Context, func_name_favorites: str) -> bool: """Factory which helps to download items from the favorites collections. - :param ctx: Typer context object. - :type ctx: typer.Context - :param func_name_favorites: Method name to call from `tidalapi` favorites object. - :type func_name_favorites: str - :return: Download result. - :rtype: bool + Args: + ctx (typer.Context): Typer context object. + func_name_favorites (str): Method name to call from `tidalapi` favorites object. + + Returns: + bool: Download result. """ # Call login method to validate the token. ctx.invoke(login, ctx) @@ -395,6 +486,11 @@ def _download_fav_factory(ctx: typer.Context, func_name_favorites: str) -> bool: @app.command() def gui(ctx: typer.Context): + """Launch the GUI for the application. + + Args: + ctx (typer.Context): Typer context object. + """ from tidal_dl_ng.gui import gui_activate ctx.invoke(login, ctx) @@ -404,9 +500,9 @@ def gui(ctx: typer.Context): def handle_sigint_term(signum, frame): """Set app abort event, so threads can check it and shutdown. - :param signum: - :param frame: - :return: + Args: + signum: Signal number. + frame: Current stack frame. """ handling_app: HandlingApp = HandlingApp() From 6b99c4ba625b8164ac8c1a142c96a31f6f88223a Mon Sep 17 00:00:00 2001 From: Robert Honz Date: Tue, 1 Jul 2025 13:19:28 +0200 Subject: [PATCH 11/27] =?UTF-8?q?fix(cli):=20=F0=9F=90=9B=20Correct=20type?= =?UTF-8?q?=20for=20`item=5Fid`=20in=20`=5Fhandle=5Falbum=5Fplaylist=5Fmix?= =?UTF-8?q?=5Fartist`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Changed `item_id` type from `int` to `str` for better consistency. * Updated related list type from `list[int]` to `list[str]`. * Added type checks for `media_type`, `item_id`, and `file_template` in `_process_url` for improved error handling. --- tidal_dl_ng/cli.py | 48 +++++++++++++++++++++++++--------------------- 1 file changed, 26 insertions(+), 22 deletions(-) diff --git a/tidal_dl_ng/cli.py b/tidal_dl_ng/cli.py index 966ed88..72ecf06 100644 --- a/tidal_dl_ng/cli.py +++ b/tidal_dl_ng/cli.py @@ -83,7 +83,7 @@ def _handle_album_playlist_mix_artist( settings: Settings, media_type: MediaType, media: object, - item_id: int, + item_id: str, file_template: str, ) -> bool: """Handle downloading albums, playlists, mixes, or artist collections. @@ -95,17 +95,17 @@ def _handle_album_playlist_mix_artist( settings (Settings): The Settings instance. media_type (MediaType): The type of media (album, playlist, mix, or artist). media: The media object to download. - item_id (int): The ID of the media item. + item_id (str): The ID of the media item. file_template (str): The file template for saving the media. Returns: bool: False if aborted, True otherwise. """ - item_ids: list[int] = [] + item_ids: list[str] = [] if media_type == MediaType.ARTIST: media_type = MediaType.ALBUM - item_ids = item_ids + all_artist_album_ids(media) + item_ids += all_artist_album_ids(media) else: item_ids.append(item_id) @@ -154,12 +154,23 @@ def _process_url( print(f"It seems like that you have supplied an invalid URL: {item}") return True - media_type: MediaType = get_tidal_media_type(item) - item_id: int = get_tidal_media_id(item) - file_template: str = get_format_template(media_type, settings) + media_type = get_tidal_media_type(item) + if not isinstance(media_type, MediaType): + print(f"Could not determine media type for: {item}") + return True + + item_id = get_tidal_media_id(item) + if not isinstance(item_id, str): + print(f"Could not determine media id for: {item}") + return True + + file_template = get_format_template(media_type, settings) + if not isinstance(file_template, str): + print(f"Could not determine file template for: {item}") + return True try: - media: object = instantiate_media(ctx.obj[CTX_TIDAL].session, media_type, item_id) + media = instantiate_media(ctx.obj[CTX_TIDAL].session, media_type, item_id) except Exception: print(f"Media not found (ID: {item_id}). Maybe it is not available anymore.") return True @@ -264,7 +275,7 @@ def settings_management( editor: Annotated[ bool, typer.Option("--editor", "-e", help="Open the settings file in your default editor.") ] = False, -): +) -> None: """Print or set an option, or open the settings file in an editor. Args: @@ -287,12 +298,11 @@ def settings_management( if names: if names[0] not in d_settings: print(f'Option "{names[0]}" is not valid!') - else: - if len(names) == 1: - print(f'{names[0]}: "{d_settings[names[0]]}"') - elif len(names) > 1: - settings.set_option(names[0], names[1]) - settings.save() + elif len(names) == 1: + print(f'{names[0]}: "{d_settings[names[0]]}"') + elif len(names) > 1: + settings.set_option(names[0], names[1]) + settings.save() else: help_settings: dict = HelpSettings().to_dict() table = Table(title=f"Config: {path_file_settings()}") @@ -300,7 +310,6 @@ def settings_management( table.add_column("Value", style="magenta") table.add_column("Description", style="green") - # Iterate over the attributes of the dataclass for key, value in sorted(d_settings.items()): table.add_row(key, str(value), help_settings[key]) @@ -473,14 +482,9 @@ def _download_fav_factory(ctx: typer.Context, func_name_favorites: str) -> bool: Returns: bool: Download result. """ - # Call login method to validate the token. ctx.invoke(login, ctx) - - # Get the method from the module func_favorites: Callable = getattr(ctx.obj[CTX_TIDAL].session.user.favorites, func_name_favorites) - # Get favorite videos - media_urls: [str] = [media.share_url for media in func_favorites()] - + media_urls: list[str] = [media.share_url for media in func_favorites()] return _download(ctx, media_urls, try_login=False) From aa532f62fc5410470e607d51b7cfecfb2768bf61 Mon Sep 17 00:00:00 2001 From: Robert Honz Date: Tue, 1 Jul 2025 13:35:17 +0200 Subject: [PATCH 12/27] =?UTF-8?q?refactor(gui):=20=E2=9C=A8=20Improve=20`s?= =?UTF-8?q?earch=5Fresult=5Fto=5Fmodel`=20method=20and=20extract=20helper?= =?UTF-8?q?=20functions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Refactored `search_result_to_model` to use type hints with `list` instead of `[*SearchTypes]`. * Added detailed docstring for better understanding of parameters and return types. * Extracted logic into helper methods for better readability and maintainability. * Improved handling of item availability and explicit content representation. --- tidal_dl_ng/gui.py | 260 ++++++++++++++++++++++++++------------------- 1 file changed, 148 insertions(+), 112 deletions(-) diff --git a/tidal_dl_ng/gui.py b/tidal_dl_ng/gui.py index 73da806..fb63632 100644 --- a/tidal_dl_ng/gui.py +++ b/tidal_dl_ng/gui.py @@ -618,126 +618,162 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): return result - def search_result_to_model(self, items: [*SearchTypes]) -> [ResultItem]: + def search_result_to_model(self, items: list[SearchTypes]) -> list[ResultItem]: + """Convert search results to ResultItem models. + + Args: + items (list[SearchTypes]): List of search result items. + + Returns: + list[ResultItem]: List of ResultItem models. + """ result = [] for idx, item in enumerate(items): - if not item: - continue - - explicit: str = "" - # Check if item is available on TIDAL. - if hasattr(item, "available") and not item.available: - continue - - if isinstance(item, Track | Video | Album): - explicit = " šŸ…“" if item.explicit else "" - - date_user_added: str = item.user_date_added.strftime("%Y-%m-%d_%H:%M") if item.user_date_added else "" - date_release: str = ( - item.album.release_date.strftime("%Y-%m-%d_%H:%M") - if hasattr(item, "album") and item.album and item.album.release_date - else ( - item.release_date.strftime("%Y-%m-%d_%H:%M") - if hasattr(item, "release_date") and item.release_date - else "" - ) - ) - - if isinstance(item, Track): - result_item: ResultItem = ResultItem( - position=idx, - artist=name_builder_artist(item), - title=f"{name_builder_title(item)}{explicit}", - album=item.album.name, - duration_sec=item.duration, - obj=item, - quality=quality_audio_highest(item), - explicit=bool(item.explicit), - date_user_added=date_user_added, - date_release=date_release, - ) - - result.append(result_item) - elif isinstance(item, Video): - result_item: ResultItem = ResultItem( - position=idx, - artist=name_builder_artist(item), - title=f"{name_builder_title(item)}{explicit}", - album=item.album.name if item.album else "", - duration_sec=item.duration, - obj=item, - quality=item.video_quality, - explicit=bool(item.explicit), - date_user_added=date_user_added, - date_release=date_release, - ) - - result.append(result_item) - elif isinstance(item, Playlist): - result_item: ResultItem = ResultItem( - position=idx, - artist=", ".join(artist.name for artist in item.promoted_artists) if item.promoted_artists else "", - title=item.name, - album="", - duration_sec=item.duration, - obj=item, - quality="", - explicit=False, - date_user_added=date_user_added, - date_release=date_release, - ) - - result.append(result_item) - elif isinstance(item, Album): - result_item: ResultItem = ResultItem( - position=idx, - artist=name_builder_artist(item), - title="", - album=f"{item.name}{explicit}", - duration_sec=item.duration, - obj=item, - quality=quality_audio_highest(item), - explicit=bool(item.explicit), - date_user_added=date_user_added, - date_release=date_release, - ) - - result.append(result_item) - elif isinstance(item, Mix): - result_item: ResultItem = ResultItem( - position=idx, - artist=item.sub_title, - title=item.title, - album="", - # TODO: Calculate total duration. - duration_sec=-1, - obj=item, - quality="", - explicit=False, - date_user_added=date_user_added, - date_release=date_release, - ) - - result.append(result_item) - elif isinstance(item, Artist): - result_item: ResultItem = ResultItem( - position=idx, - artist=item.name, - title="", - album="", - duration_sec=-1, - obj=item, - quality="", - explicit=False, - date_user_added=date_user_added, - date_release=date_release, - ) + result_item = self._to_result_item(idx, item) + if result_item is not None: result.append(result_item) return result + def _to_result_item(self, idx: int, item) -> ResultItem | None: + """Helper to convert a single item to ResultItem, or None if not valid.""" + if not item: + return None + + if hasattr(item, "available") and not item.available: + return None + + explicit: str = "" + if isinstance(item, Track | Video | Album): + explicit = " šŸ…“" if item.explicit else "" + + date_user_added: str = ( + item.user_date_added.strftime("%Y-%m-%d_%H:%M") if getattr(item, "user_date_added", None) else "" + ) + date_release: str = self._get_date_release(item) + + if isinstance(item, Track): + return self._result_item_from_track(idx, item, explicit, date_user_added, date_release) + + if isinstance(item, Video): + return self._result_item_from_video(idx, item, explicit, date_user_added, date_release) + + if isinstance(item, Playlist): + return self._result_item_from_playlist(idx, item, date_user_added, date_release) + + if isinstance(item, Album): + return self._result_item_from_album(idx, item, explicit, date_user_added, date_release) + + if isinstance(item, Mix): + return self._result_item_from_mix(idx, item, date_user_added, date_release) + + if isinstance(item, Artist): + return self._result_item_from_artist(idx, item, date_user_added, date_release) + + return None + + def _get_date_release(self, item) -> str: + """Get the release date string for an item.""" + if hasattr(item, "album") and item.album and getattr(item.album, "release_date", None): + return item.album.release_date.strftime("%Y-%m-%d_%H:%M") + + if hasattr(item, "release_date") and item.release_date: + return item.release_date.strftime("%Y-%m-%d_%H:%M") + + return "" + + def _result_item_from_track( + self, idx: int, item, explicit: str, date_user_added: str, date_release: str + ) -> ResultItem: + return ResultItem( + position=idx, + artist=name_builder_artist(item), + title=f"{name_builder_title(item)}{explicit}", + album=item.album.name, + duration_sec=item.duration, + obj=item, + quality=quality_audio_highest(item), + explicit=bool(item.explicit), + date_user_added=date_user_added, + date_release=date_release, + ) + + def _result_item_from_video( + self, idx: int, item, explicit: str, date_user_added: str, date_release: str + ) -> ResultItem: + return ResultItem( + position=idx, + artist=name_builder_artist(item), + title=f"{name_builder_title(item)}{explicit}", + album=item.album.name if item.album else "", + duration_sec=item.duration, + obj=item, + quality=item.video_quality, + explicit=bool(item.explicit), + date_user_added=date_user_added, + date_release=date_release, + ) + + def _result_item_from_playlist(self, idx: int, item, date_user_added: str, date_release: str) -> ResultItem: + return ResultItem( + position=idx, + artist=", ".join(artist.name for artist in item.promoted_artists) if item.promoted_artists else "", + title=item.name, + album="", + duration_sec=item.duration, + obj=item, + quality="", + explicit=False, + date_user_added=date_user_added, + date_release=date_release, + ) + + def _result_item_from_album( + self, idx: int, item, explicit: str, date_user_added: str, date_release: str + ) -> ResultItem: + return ResultItem( + position=idx, + artist=name_builder_artist(item), + title="", + album=f"{item.name}{explicit}", + duration_sec=item.duration, + obj=item, + quality=quality_audio_highest(item), + explicit=bool(item.explicit), + date_user_added=date_user_added, + date_release=date_release, + ) + + def _result_item_from_mix(self, idx: int, item, date_user_added: str, date_release: str) -> ResultItem: + return ResultItem( + position=idx, + artist=item.sub_title, + title=item.title, + album="", + duration_sec=-1, # TODO: Calculate total duration. + obj=item, + quality="", + explicit=False, + date_user_added=date_user_added, + date_release=date_release, + ) + + def _result_item_from_artist(self, idx: int, item, date_user_added: str, date_release: str) -> ResultItem: + return ResultItem( + position=idx, + artist=item.name, + title="", + album="", + duration_sec=-1, + obj=item, + quality="", + explicit=False, + date_user_added=date_user_added, + date_release=date_release, + ) + def media_to_queue_download_model( self, media: Artist | Track | Video | Album | Playlist | Mix ) -> QueueDownloadItem | bool: From d8a6c26d658f46040b7fcdc3097ce25cc196f67b Mon Sep 17 00:00:00 2001 From: Robert Honz Date: Tue, 1 Jul 2025 14:30:10 +0200 Subject: [PATCH 13/27] =?UTF-8?q?refactor(gui):=20=E2=9C=A8=20Enhance=20do?= =?UTF-8?q?cumentation=20for=20`MainWindow`=20methods?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Added detailed docstrings for methods in the `MainWindow` class. * Improved clarity on parameters and return types for better understanding. * Aims to facilitate future development and maintenance. --- tidal_dl_ng/gui.py | 492 +++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 451 insertions(+), 41 deletions(-) diff --git a/tidal_dl_ng/gui.py b/tidal_dl_ng/gui.py index fb63632..e931f38 100644 --- a/tidal_dl_ng/gui.py +++ b/tidal_dl_ng/gui.py @@ -106,6 +106,11 @@ from tidal_dl_ng.worker import Worker # TODO: Make more use of Exceptions class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): + """Main application window for TIDAL Downloader Next Generation. + + Handles GUI setup, user interactions, and download logic. + """ + settings: Settings tidal: Tidal dl: Download @@ -138,6 +143,11 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): s_queue_download_item_skipped: QtCore.Signal = QtCore.Signal(object) def __init__(self, tidal: Tidal | None = None): + """Initialize the main window and all components. + + Args: + tidal (Tidal | None): Optional Tidal session object. + """ super().__init__() self.setupUi(self) # self.setGeometry(50, 50, 500, 300) @@ -168,9 +178,15 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): logger_gui.debug("All setup.") def _init_gui(self): + """Initialize GUI-specific variables and state.""" self.spinners = {} def init_tidal(self, tidal: Tidal = None): + """Initialize Tidal session and handle login flow. + + Args: + tidal (Tidal, optional): Existing Tidal session. Defaults to None. + """ result: bool = False if tidal: @@ -211,10 +227,12 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.thread_it(self.tidal_user_lists) def _init_threads(self): + """Initialize thread pool and start background workers.""" self.threadpool = QtCore.QThreadPool() self.thread_it(self.watcher_queue_download) def _init_dl(self): + """Initialize Download object and related progress bars.""" # Init `Download` object. data_pb: ProgressBars = ProgressBars( item=self.s_item_advance, @@ -236,6 +254,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): ) def _init_progressbar(self): + """Initialize and add progress bars to the status bar.""" self.pb_list = QtWidgets.QProgressBar() self.pb_item = QtWidgets.QProgressBar() pbs = [self.pb_list, self.pb_item] @@ -246,18 +265,30 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.statusbar.addPermanentWidget(pb) def _init_info(self): + """Set default album cover image in the GUI.""" path_image: str = resource_path("tidal_dl_ng/ui/default_album_image.png") self.l_pm_cover.setPixmap(QtGui.QPixmap(path_image)) def on_progress_reset(self): + """Reset progress bars to zero.""" self.pb_list.setValue(0) self.pb_item.setValue(0) def on_statusbar_message(self, data: StatusbarMessage): + """Show a message in the status bar. + + Args: + data (StatusbarMessage): Message and timeout. + """ self.statusbar.showMessage(data.message, data.timeout) - def _log_output(self, text): + def _log_output(self, text: str) -> None: + """Redirect log output to the debug text area. + + Args: + text (str): Log message. + """ display_msg = coloredlogs.converter.convert(text) cursor: QtGui.QTextCursor = self.te_debug.textCursor() @@ -267,18 +298,31 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.te_debug.setTextCursor(cursor) self.te_debug.ensureCursorVisible() - def _populate_quality(self, ui_target: QtWidgets.QComboBox, options: type[Quality | QualityVideo]): + def _populate_quality(self, ui_target: QtWidgets.QComboBox, options: type[Quality | QualityVideo]) -> None: + """Populate a combo box with quality options. + + Args: + ui_target (QComboBox): Target combo box. + options (type): Enum of quality options. + """ for item in options: ui_target.addItem(item.name, item) - def _populate_search_types(self, ui_target: QtWidgets.QComboBox, options: SearchTypes): + def _populate_search_types(self, ui_target: QtWidgets.QComboBox, options: SearchTypes) -> None: + """Populate a combo box with search type options. + + Args: + ui_target (QComboBox): Target combo box. + options (SearchTypes): Enum of search types. + """ for item in options: if item: ui_target.addItem(item.__name__, item) self.cb_search_type.setCurrentIndex(2) - def handle_filter_activated(self): + def handle_filter_activated(self) -> None: + """Handle activation of filter headers in the results tree.""" header: FilterHeader = self.tr_results.header() filters = [] @@ -292,6 +336,12 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): proxy_model.filters = filters def _init_tree_results(self, tree: QtWidgets.QTreeView, model: QtGui.QStandardItemModel) -> None: + """Initialize the results tree view and its model. + + Args: + tree (QTreeView): The tree view widget. + model (QStandardItemModel): The model for the tree. + """ header: FilterHeader = FilterHeader(tree) self.proxy_tr_results: HumanProxyModel = HumanProxyModel(self) @@ -313,13 +363,23 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): tree.customContextMenuRequested.connect(self.menu_context_tree_results) def _init_tree_results_model(self, model: QtGui.QStandardItemModel) -> None: + """Initialize the model for the results tree view. + + Args: + model (QStandardItemModel): The model to initialize. + """ labels_column: [str] = ["#", "obj", "Artist", "Title", "Album", "Duration", "Quality", "Date"] model.setColumnCount(len(labels_column)) model.setRowCount(0) model.setHorizontalHeaderLabels(labels_column) - def _init_tree_queue(self, tree: QtWidgets.QTableWidget): + def _init_tree_queue(self, tree: QtWidgets.QTableWidget) -> None: + """Initialize the download queue table widget. + + Args: + tree (QTableWidget): The table widget. + """ tree.setColumnHidden(1, True) tree.setColumnWidth(2, 200) @@ -328,7 +388,8 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): header.setSectionResizeMode(0, QtWidgets.QHeaderView.ResizeToContents) header.setStretchLastSection(False) - def tidal_user_lists(self): + def tidal_user_lists(self) -> None: + """Fetch and emit user playlists, mixes, and favorites from Tidal.""" # Start loading spinner self.s_spinner_start.emit(self.tr_lists_user) self.s_pb_reload_status.emit(False) @@ -337,7 +398,12 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.s_populate_tree_lists.emit(user_all) - def on_populate_tree_lists(self, user_lists: [Playlist | UserPlaylist | Mix]): + def on_populate_tree_lists(self, user_lists: list[Playlist | UserPlaylist | Mix]) -> None: + """Populate the user lists tree with playlists, mixes, and favorites. + + Args: + user_lists (list): List of user playlists, mixes, and favorites. + """ twi_playlists: QtWidgets.QTreeWidgetItem = self.tr_lists_user.findItems( TidalLists.Playlists, QtCore.Qt.MatchExactly, 0 )[0] @@ -389,7 +455,12 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.s_spinner_stop.emit() self.s_pb_reload_status.emit(True) - def _init_tree_lists(self, tree: QtWidgets.QTreeWidget): + def _init_tree_lists(self, tree: QtWidgets.QTreeWidget) -> None: + """Initialize the user lists tree widget. + + Args: + tree (QTreeWidget): The tree widget. + """ # Adjust Tree. tree.setColumnWidth(0, 200) tree.setColumnHidden(1, True) @@ -400,13 +471,23 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): tree.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) tree.customContextMenuRequested.connect(self.menu_context_tree_lists) - def on_update_check(self, on_startup: bool = True): + def on_update_check(self, on_startup: bool = True) -> None: + """Check for application updates and emit update signals. + + Args: + on_startup (bool, optional): Whether this is called on startup. Defaults to True. + """ is_available, info = update_available() if (on_startup and is_available) or not on_startup: self.s_update_show.emit(True, is_available, info) - def apply_settings(self, settings: Settings): + def apply_settings(self, settings: Settings) -> None: + """Apply user settings to the GUI. + + Args: + settings (Settings): The settings object. + """ l_cb = [ {"element": self.cb_quality_audio, "setting": settings.data.quality_audio, "default_id": 1}, {"element": self.cb_quality_video, "setting": settings.data.quality_video, "default_id": 0}, @@ -420,7 +501,12 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): else: item["element"].setCurrentIndex(item["default_id"]) - def on_spinner_start(self, parent: QtWidgets.QWidget): + def on_spinner_start(self, parent: QtWidgets.QWidget) -> None: + """Start a loading spinner on the given parent widget. + + Args: + parent (QWidget): The parent widget. + """ # Stop any existing spinner for this parent if parent in self.spinners: spinner = self.spinners[parent] @@ -437,7 +523,8 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.spinners[parent] = spinner - def on_spinner_stop(self): + def on_spinner_stop(self) -> None: + """Stop all active loading spinners.""" # Stop all spinners for spinner in list(self.spinners.values()): spinner.stop() @@ -445,7 +532,12 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.spinners.clear() - def menu_context_tree_lists(self, point: QtCore.QPoint): + def menu_context_tree_lists(self, point: QtCore.QPoint) -> None: + """Show context menu for user lists tree. + + Args: + point (QPoint): The point where the menu is requested. + """ # Infos about the node selected. index = self.tr_lists_user.indexAt(point) @@ -460,7 +552,12 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): menu.exec(self.tr_lists_user.mapToGlobal(point)) - def menu_context_tree_results(self, point: QtCore.QPoint): + def menu_context_tree_results(self, point: QtCore.QPoint) -> None: + """Show context menu for results tree. + + Args: + point (QPoint): The point where the menu is requested. + """ # Infos about the node selected. index = self.tr_results.indexAt(point) @@ -474,10 +571,23 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): menu.exec(self.tr_results.mapToGlobal(point)) - def thread_download_list_media(self, point: QtCore.QPoint): + def thread_download_list_media(self, point: QtCore.QPoint) -> None: + """Start download of a list media item in a thread. + + Args: + point (QPoint): The point in the tree. + """ self.thread_it(self.on_download_list_media, point) - def on_copy_url_share(self, tree_target: QtWidgets.QTreeWidget | QtWidgets.QTreeView, point: QtCore.QPoint = None): + def on_copy_url_share( + self, tree_target: QtWidgets.QTreeWidget | QtWidgets.QTreeView, point: QtCore.QPoint = None + ) -> None: + """Copy the share URL of a media item to the clipboard. + + Args: + tree_target (QTreeWidget | QTreeView): The tree widget. + point (QPoint, optional): The point in the tree. Defaults to None. + """ if isinstance(tree_target, QtWidgets.QTreeWidget): item: QtWidgets.QTreeWidgetItem = tree_target.itemAt(point) @@ -494,7 +604,12 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): clipboard.clear() clipboard.setText(url_share) - def on_download_list_media(self, point: QtCore.QPoint = None): + def on_download_list_media(self, point: QtCore.QPoint = None) -> None: + """Download all media items in a selected list. + + Args: + point (QPoint, optional): The point in the tree. Defaults to None. + """ items: [QtWidgets.QTreeWidgetItem] if point: @@ -512,14 +627,26 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): if queue_dl_item: self.queue_download_media(queue_dl_item) - def search_populate_results(self, query: str, type_media: SearchTypes): + def search_populate_results(self, query: str, type_media: SearchTypes) -> None: + """Populate the results tree with search results. + + Args: + query (str): The search query. + type_media (SearchTypes): The type of media to search for. + """ self.model_tr_results.removeRows(0, self.model_tr_results.rowCount()) results: [ResultItem] = self.search(query, [type_media]) self.populate_tree_results(results) - def populate_tree_results(self, results: [ResultItem], parent: QtGui.QStandardItem = None): + def populate_tree_results(self, results: list[ResultItem], parent: QtGui.QStandardItem = None) -> None: + """Populate the results tree with ResultItem objects. + + Args: + results (list[ResultItem]): The results to display. + parent (QStandardItem, optional): Parent item for nested results. Defaults to None. + """ if not parent: self.model_tr_results.removeRows(0, self.model_tr_results.rowCount()) @@ -535,6 +662,15 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.s_tr_results_add_top_level_item.emit(child) def populate_tree_result_child(self, item: ResultItem, index_count_digits: int) -> Sequence[QtGui.QStandardItem]: + """Create a row of QStandardItems for a ResultItem. + + Args: + item (ResultItem): The result item. + index_count_digits (int): Number of digits for index formatting. + + Returns: + Sequence[QStandardItem]: The row of items. + """ duration: str = "" # TODO: Duration needs to be calculated later to properly fill with zeros. @@ -582,14 +718,29 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): ) def on_tr_results_add_top_level_item(self, item_child: Sequence[QtGui.QStandardItem]): + """Add a top-level item to the results tree model. + + Args: + item_child (Sequence[QStandardItem]): The row to add. + """ self.model_tr_results.appendRow(item_child) - def on_settings_save(self): + def on_settings_save(self) -> None: + """Save settings and re-apply them to the GUI.""" self.settings.save() self.apply_settings(self.settings) self._init_dl() - def search(self, query: str, types_media: SearchTypes) -> [ResultItem]: + def search(self, query: str, types_media: SearchTypes) -> list[ResultItem]: + """Perform a search and return a list of ResultItems. + + Args: + query (str): The search query. + types_media (SearchTypes): The types of media to search for. + + Returns: + list[ResultItem]: The search results. + """ query = query.strip() # If a direct link was searched for, skip search and create the object from the link directly. @@ -638,7 +789,15 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): return result def _to_result_item(self, idx: int, item) -> ResultItem | None: - """Helper to convert a single item to ResultItem, or None if not valid.""" + """Helper to convert a single item to ResultItem, or None if not valid. + + Args: + idx (int): Index of the item. + item: The item to convert. + + Returns: + ResultItem | None: The converted ResultItem or None if not valid. + """ if not item: return None @@ -675,7 +834,14 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): return None def _get_date_release(self, item) -> str: - """Get the release date string for an item.""" + """Get the release date string for an item. + + Args: + item: The item to extract the release date from. + + Returns: + str: The formatted release date or empty string. + """ if hasattr(item, "album") and item.album and getattr(item.album, "release_date", None): return item.album.release_date.strftime("%Y-%m-%d_%H:%M") @@ -687,6 +853,18 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): def _result_item_from_track( self, idx: int, item, explicit: str, date_user_added: str, date_release: str ) -> ResultItem: + """Create a ResultItem from a Track. + + Args: + idx (int): Index of the item. + item: The Track item. + explicit (str): Explicit tag. + date_user_added (str): Date user added. + date_release (str): Release date. + + Returns: + ResultItem: The constructed ResultItem. + """ return ResultItem( position=idx, artist=name_builder_artist(item), @@ -703,6 +881,18 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): def _result_item_from_video( self, idx: int, item, explicit: str, date_user_added: str, date_release: str ) -> ResultItem: + """Create a ResultItem from a Video. + + Args: + idx (int): Index of the item. + item: The Video item. + explicit (str): Explicit tag. + date_user_added (str): Date user added. + date_release (str): Release date. + + Returns: + ResultItem: The constructed ResultItem. + """ return ResultItem( position=idx, artist=name_builder_artist(item), @@ -717,6 +907,17 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): ) def _result_item_from_playlist(self, idx: int, item, date_user_added: str, date_release: str) -> ResultItem: + """Create a ResultItem from a Playlist. + + Args: + idx (int): Index of the item. + item: The Playlist item. + date_user_added (str): Date user added. + date_release (str): Release date. + + Returns: + ResultItem: The constructed ResultItem. + """ return ResultItem( position=idx, artist=", ".join(artist.name for artist in item.promoted_artists) if item.promoted_artists else "", @@ -733,6 +934,18 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): def _result_item_from_album( self, idx: int, item, explicit: str, date_user_added: str, date_release: str ) -> ResultItem: + """Create a ResultItem from an Album. + + Args: + idx (int): Index of the item. + item: The Album item. + explicit (str): Explicit tag. + date_user_added (str): Date user added. + date_release (str): Release date. + + Returns: + ResultItem: The constructed ResultItem. + """ return ResultItem( position=idx, artist=name_builder_artist(item), @@ -747,6 +960,17 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): ) def _result_item_from_mix(self, idx: int, item, date_user_added: str, date_release: str) -> ResultItem: + """Create a ResultItem from a Mix. + + Args: + idx (int): Index of the item. + item: The Mix item. + date_user_added (str): Date user added. + date_release (str): Release date. + + Returns: + ResultItem: The constructed ResultItem. + """ return ResultItem( position=idx, artist=item.sub_title, @@ -761,6 +985,17 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): ) def _result_item_from_artist(self, idx: int, item, date_user_added: str, date_release: str) -> ResultItem: + """Create a ResultItem from an Artist. + + Args: + idx (int): Index of the item. + item: The Artist item. + date_user_added (str): Date user added. + date_release (str): Release date. + + Returns: + ResultItem: The constructed ResultItem. + """ return ResultItem( position=idx, artist=item.name, @@ -777,6 +1012,14 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): def media_to_queue_download_model( self, media: Artist | Track | Video | Album | Playlist | Mix ) -> QueueDownloadItem | bool: + """Convert a media object to a QueueDownloadItem for the download queue. + + Args: + media (Artist | Track | Video | Album | Playlist | Mix): The media object. + + Returns: + QueueDownloadItem | bool: The queue item or False if not available. + """ result: QueueDownloadItem | False name: str = "" quality_audio: Quality = self.settings.data.quality_audio @@ -825,7 +1068,8 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): return result - def _init_signals(self): + def _init_signals(self) -> None: + """Connect signals to their respective slots.""" self.pb_download.clicked.connect(lambda: self.thread_it(self.on_download_results)) self.pb_download_list.clicked.connect(lambda: self.thread_it(self.on_download_list_media)) self.pb_reload_user_lists.clicked.connect(lambda: self.thread_it(self.tidal_user_lists)) @@ -875,45 +1119,87 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.s_queue_download_item_failed.connect(self.on_queue_download_item_failed) self.s_queue_download_item_skipped.connect(self.on_queue_download_item_skipped) - def _init_buttons(self): + def _init_buttons(self) -> None: + """Initialize the state of the download buttons.""" self.pb_queue_download_run() - def on_logout(self): + def on_logout(self) -> None: + """Log out from TIDAL and close the application.""" result: bool = self.tidal.logout() if result: sys.exit(0) - def on_progress_list(self, value: float): + def on_progress_list(self, value: float) -> None: + """Update the progress of the list progress bar. + + Args: + value (float): The progress value as a percentage. + """ self.pb_list.setValue(int(math.ceil(value))) - def on_progress_item(self, value: float): + def on_progress_item(self, value: float) -> None: + """Update the progress of the item progress bar. + + Args: + value (float): The progress value as a percentage. + """ self.pb_item.setValue(int(math.ceil(value))) - def on_progress_item_name(self, value: str): + def on_progress_item_name(self, value: str) -> None: + """Set the format of the item progress bar. + + Args: + value (str): The item name. + """ self.pb_item.setFormat(f"%p% {value}") - def on_progress_list_name(self, value: str): + def on_progress_list_name(self, value: str) -> None: + """Set the format of the list progress bar. + + Args: + value (str): The list name. + """ self.pb_list.setFormat(f"%p% {value}") - def on_quality_set_audio(self, index): + def on_quality_set_audio(self, index: int) -> None: + """Set the audio quality for downloads. + + Args: + index: The index of the selected quality in the combo box. + """ self.settings.data.quality_audio = Quality(self.cb_quality_audio.itemData(index)) self.settings.save() if self.tidal: self.tidal.settings_apply() - def on_quality_set_video(self, index): + def on_quality_set_video(self, index: int) -> None: + """Set the video quality for downloads. + + Args: + index: The index of the selected quality in the combo box. + """ self.settings.data.quality_video = QualityVideo(self.cb_quality_video.itemData(index)) self.settings.save() if self.tidal: self.tidal.settings_apply() - def on_list_items_show(self, item: QtWidgets.QTreeWidgetItem): + def on_list_items_show(self, item: QtWidgets.QTreeWidgetItem) -> None: + """Show the items in the selected playlist or mix. + + Args: + item (QtWidgets.QTreeWidgetItem): The selected tree widget item. + """ self.thread_it(self.list_items_show, item) - def list_items_show(self, item: QtWidgets.QTreeWidgetItem): + def list_items_show(self, item: QtWidgets.QTreeWidgetItem) -> None: + """Fetch and display the items in a playlist or mix. + + Args: + item (QtWidgets.QTreeWidgetItem): The tree widget item representing a playlist or mix. + """ media_list: Album | Playlist | str = get_user_list_media_item(item) # Only if clicked item is not a top level item. @@ -932,6 +1218,11 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.s_spinner_stop.emit() def on_result_item_clicked(self, index: QtCore.QModelIndex) -> None: + """Handle the event when a result item is clicked. + + Args: + index (QtCore.QModelIndex): The index of the clicked item. + """ media: Track | Video | Album | Artist = get_results_media_item( index, self.proxy_tr_results, self.model_tr_results ) @@ -940,12 +1231,23 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.thread_it(self.cover_show, media) def on_queue_download_item_clicked(self, item: QtWidgets.QTreeWidgetItem, column: int) -> None: + """Handle the event when a queue download item is clicked. + + Args: + item (QtWidgets.QTreeWidgetItem): The clicked tree widget item. + column (int): The column index of the clicked item. + """ media: Track | Video | Album | Artist | Mix | Playlist = get_queue_download_media(item) # Load cover asynchronously to avoid blocking the GUI self.thread_it(self.cover_show, media) def cover_show(self, media: Album | Playlist | Track | Video | Album | Artist) -> None: + """Show the cover image of the selected media item. + + Args: + media (Album | Playlist | Track | Video | Album | Artist): The media item. + """ cover_url: str = "" # Show spinner in the cover label itself parent_widget = self.l_pm_cover @@ -987,6 +1289,14 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): parent: QtGui.QStandardItem = None, favorite_function: Callable = None, ) -> None: + """Populate the results tree with the items of a media list. + + Args: + media_list (Album | Playlist | Mix | Artist | None, optional): The media list to show. Defaults to None. + point (QPoint | None, optional): The point in the tree. Defaults to None. + parent (QStandardItem, optional): Parent item for nested results. Defaults to None. + favorite_function (Callable, optional): Function to fetch favorite items. Defaults to None. + """ if point: item = self.tr_lists_user.itemAt(point) media_list = get_user_list_media_item(item) @@ -1004,22 +1314,36 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.populate_tree_results(result, parent=parent) - def thread_it(self, fn: Callable, *args, **kwargs): + def thread_it(self, fn: Callable, *args, **kwargs) -> None: + """Run a function in a separate thread. + + Args: + fn (Callable): The function to run. + *args: Positional arguments for the function. + **kwargs: Keyword arguments for the function. + """ # Any other args, kwargs are passed to the run function worker = Worker(fn, *args, **kwargs) # Execute self.threadpool.start(worker) - def on_queue_download_clear_all(self): + def on_queue_download_clear_all(self) -> None: + """Clear all items from the download queue.""" self.on_clear_queue_download( f"({QueueDownloadStatus.Waiting}|{QueueDownloadStatus.Finished}|{QueueDownloadStatus.Failed})" ) - def on_queue_download_clear_finished(self): + def on_queue_download_clear_finished(self) -> None: + """Clear finished items from the download queue.""" self.on_clear_queue_download(f"[{QueueDownloadStatus.Finished}]") - def on_clear_queue_download(self, regex: str): + def on_clear_queue_download(self, regex: str) -> None: + """Clear items from the download queue matching the given regex. + + Args: + regex (str): Regular expression to match items. + """ items: [QtWidgets.QTreeWidgetItem | None] = self.tr_queue_download.findItems( regex, QtCore.Qt.MatchFlag.MatchRegularExpression, column=0 ) @@ -1027,7 +1351,8 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): for item in items: self.tr_queue_download.takeTopLevelItem(self.tr_queue_download.indexOfTopLevelItem(item)) - def on_queue_download_remove(self): + def on_queue_download_remove(self) -> None: + """Remove selected items from the download queue.""" items: [QtWidgets.QTreeWidgetItem | None] = self.tr_queue_download.selectedItems() if len(items) == 0: @@ -1053,7 +1378,8 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): else: self.pb_queue_download_run() - def pb_queue_download_run(self): + def pb_queue_download_run(self) -> None: + """Start the download queue and update the button state.""" handling_app: HandlingApp = HandlingApp() handling_app.event_run.set() @@ -1062,7 +1388,8 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.pb_queue_download_toggle.setIcon(icon) self.pb_queue_download_toggle.setStyleSheet("background-color: #218838; color: #fff") - def pb_queue_download_pause(self): + def pb_queue_download_pause(self) -> None: + """Pause the download queue and update the button state.""" handling_app: HandlingApp = HandlingApp() handling_app.event_run.clear() @@ -1073,6 +1400,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): # TODO: Must happen in main thread. Do not thread this. def on_download_results(self) -> None: + """Download the selected results in the results tree.""" items: [HumanProxyModel | None] = self.tr_results.selectionModel().selectedRows() if len(items) == 0: @@ -1088,6 +1416,11 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.queue_download_media(queue_dl_item) def queue_download_media(self, queue_dl_item: QueueDownloadItem) -> None: + """Add a media item to the download queue. + + Args: + queue_dl_item (QueueDownloadItem): The item to add to the queue. + """ # Populate child child: QtWidgets.QTreeWidgetItem = QtWidgets.QTreeWidgetItem() @@ -1100,6 +1433,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.tr_queue_download.addTopLevelItem(child) def watcher_queue_download(self) -> None: + """Monitor the download queue and process items as they become available.""" handling_app: HandlingApp = HandlingApp() while not handling_app.event_abort.is_set(): @@ -1129,18 +1463,44 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): time.sleep(2) def on_queue_download_item_downloading(self, item: QtWidgets.QTreeWidgetItem) -> None: + """Update the status of a queue download item to 'Downloading'. + + Args: + item (QtWidgets.QTreeWidgetItem): The item to update. + """ self.queue_download_item_status(item, QueueDownloadStatus.Downloading) def on_queue_download_item_finished(self, item: QtWidgets.QTreeWidgetItem) -> None: + """Update the status of a queue download item to 'Finished'. + + Args: + item (QtWidgets.QTreeWidgetItem): The item to update. + """ self.queue_download_item_status(item, QueueDownloadStatus.Finished) def on_queue_download_item_failed(self, item: QtWidgets.QTreeWidgetItem) -> None: + """Update the status of a queue download item to 'Failed'. + + Args: + item (QtWidgets.QTreeWidgetItem): The item to update. + """ self.queue_download_item_status(item, QueueDownloadStatus.Failed) def on_queue_download_item_skipped(self, item: QtWidgets.QTreeWidgetItem) -> None: + """Update the status of a queue download item to 'Skipped'. + + Args: + item (QtWidgets.QTreeWidgetItem): The item to update. + """ self.queue_download_item_status(item, QueueDownloadStatus.Skipped) def queue_download_item_status(self, item: QtWidgets.QTreeWidgetItem, status: str) -> None: + """Set the status text of a queue download item. + + Args: + item (QtWidgets.QTreeWidgetItem): The item to update. + status (str): The status text. + """ item.setText(0, status) def on_queue_download( @@ -1149,6 +1509,16 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): quality_audio: Quality | None = None, quality_video: QualityVideo | None = None, ) -> QueueDownloadStatus: + """Download the specified media item(s) and return the result status. + + Args: + media (Track | Album | Playlist | Video | Mix | Artist): The media item(s) to download. + quality_audio (Quality | None, optional): Desired audio quality. Defaults to None. + quality_video (QualityVideo | None, optional): Desired video quality. Defaults to None. + + Returns: + QueueDownloadStatus: The status of the download operation. + """ result: QueueDownloadStatus items_media: [Track | Album | Playlist | Video | Mix | Artist] @@ -1178,6 +1548,18 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): quality_audio: Quality | None = None, quality_video: QualityVideo | None = None, ) -> QueueDownloadStatus: + """Download a media item and return the result status. + + Args: + media (Track | Album | Playlist | Video | Mix | Artist): The media item to download. + dl (Download): The Download object to use. + delay_track (bool, optional): Whether to apply download delay. Defaults to False. + quality_audio (Quality | None, optional): Desired audio quality. Defaults to None. + quality_video (QualityVideo | None, optional): Desired video quality. Defaults to None. + + Returns: + QueueDownloadStatus: The status of the download operation. + """ result_dl: bool path_file: str result: QueueDownloadStatus @@ -1222,15 +1604,33 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): def on_version( self, update_check: bool = False, update_available: bool = False, update_info: ReleaseLatest = None ) -> None: + """Show the version information dialog. + + Args: + update_check (bool, optional): Whether to check for updates. Defaults to False. + update_available (bool, optional): Whether an update is available. Defaults to False. + update_info (ReleaseLatest, optional): Information about the latest release. Defaults to None. + """ DialogVersion(self, update_check, update_available, update_info) def on_preferences(self) -> None: + """Open the preferences dialog.""" DialogPreferences(settings=self.settings, settings_save=self.s_settings_save, parent=self) def on_tr_results_expanded(self, index: QtCore.QModelIndex) -> None: + """Handle the event when a result item group is expanded. + + Args: + index (QtCore.QModelIndex): The index of the expanded item. + """ self.thread_it(self.tr_results_expanded, index) def tr_results_expanded(self, index: QtCore.QModelIndex) -> None: + """Load and display the children of an expanded result item. + + Args: + index (QtCore.QModelIndex): The index of the expanded item. + """ # If the child is a dummy the list_item has not been expanded before item: QtGui.QStandardItem = self.model_tr_results.itemFromIndex(self.proxy_tr_results.mapToSource(index)) load_children: bool = not item.child(0, 0).isEnabled() @@ -1250,6 +1650,11 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.s_spinner_stop.emit() def button_reload_status(self, status: bool): + """Update the reload button's state and text. + + Args: + status (bool): The new status. + """ button_text: str = "Reloading..." if status: @@ -1259,6 +1664,11 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.pb_reload_user_lists.setText(button_text) def closeEvent(self, event): + """Handle the close event of the main window. + + Args: + event: The close event. + """ self.shutdown = True handling_app: HandlingApp = HandlingApp() From 395771a93904389832e0079531f53db90fdd1ac6 Mon Sep 17 00:00:00 2001 From: Robert Honz Date: Tue, 1 Jul 2025 14:36:37 +0200 Subject: [PATCH 14/27] =?UTF-8?q?refactor(guidelines):=20=E2=9C=A8=20Updat?= =?UTF-8?q?e=20coding=20standards=20for=20type=20annotations=20and=20add?= =?UTF-8?q?=20PEP=20references?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Clarified type annotations usage for function parameters, return types, and variables. * Added guidelines for using blank lines, coroutines, and string interpolation as per PEP standards. --- .github/copilot-instructions.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 472850f..c890c0c 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -19,8 +19,12 @@ applyTo: "**/*.py" ## Coding Guidelines -- Use type annotations for function parameters, return types and variables. +- Use type annotations / hints for function and method parameters, return types and variables. Follow PEP 484. - Use newest coding style which is supported by the used Python version. +- Use blank lines to keep code organized and readable. +- Follow PEP 492 – Coroutines with async and await syntax +- Follow PEP 498 – Literal String Interpolation +- Follow PEP 572 – Assignment Expressions ## Code Documentation From 76f6010a12e8972119117041ee1f737463a92c2f Mon Sep 17 00:00:00 2001 From: Robert Honz Date: Tue, 1 Jul 2025 15:52:30 +0200 Subject: [PATCH 15/27] =?UTF-8?q?refactor(gui):=20=E2=9C=A8=20Enhance=20ty?= =?UTF-8?q?pe=20annotations=20and=20improve=20method=20signatures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Updated type hints for several methods in `MainWindow` class to improve code clarity and type safety. * Changed return types to `None` where applicable. * Refined parameter types for better type checking and consistency. --- tidal_dl_ng/gui.py | 90 +++++++++++++++++++++++----------------------- 1 file changed, 45 insertions(+), 45 deletions(-) diff --git a/tidal_dl_ng/gui.py b/tidal_dl_ng/gui.py index e931f38..6cf1f41 100644 --- a/tidal_dl_ng/gui.py +++ b/tidal_dl_ng/gui.py @@ -47,7 +47,8 @@ import math import sys import time -from collections.abc import Callable, Sequence +from collections.abc import Callable, Iterable, Sequence +from typing import Any from requests.exceptions import HTTPError from tidalapi.session import LinkLogin @@ -142,7 +143,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): s_queue_download_item_failed: QtCore.Signal = QtCore.Signal(object) s_queue_download_item_skipped: QtCore.Signal = QtCore.Signal(object) - def __init__(self, tidal: Tidal | None = None): + def __init__(self, tidal: Tidal | None = None) -> None: """Initialize the main window and all components. Args: @@ -177,11 +178,11 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): logger_gui.debug("All setup.") - def _init_gui(self): + def _init_gui(self) -> None: """Initialize GUI-specific variables and state.""" - self.spinners = {} + self.spinners: dict[QtWidgets.QWidget, QtWaitingSpinner] = {} - def init_tidal(self, tidal: Tidal = None): + def init_tidal(self, tidal: Tidal | None = None): """Initialize Tidal session and handle login flow. Args: @@ -201,10 +202,11 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): while not result: link_login: LinkLogin = self.tidal.session.get_link_login() + expires_in = int(link_login.expires_in) if hasattr(link_login, "expires_in") else 0 d_login: DialogLogin = DialogLogin( url_login=link_login.verification_uri_complete, hint=hint, - expires_in=link_login.expires_in, + expires_in=expires_in, parent=self, ) @@ -298,22 +300,22 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.te_debug.setTextCursor(cursor) self.te_debug.ensureCursorVisible() - def _populate_quality(self, ui_target: QtWidgets.QComboBox, options: type[Quality | QualityVideo]) -> None: + def _populate_quality(self, ui_target: QtWidgets.QComboBox, options: Iterable[Any]) -> None: """Populate a combo box with quality options. Args: ui_target (QComboBox): Target combo box. - options (type): Enum of quality options. + options (Iterable): Enum of quality options. """ for item in options: ui_target.addItem(item.name, item) - def _populate_search_types(self, ui_target: QtWidgets.QComboBox, options: SearchTypes) -> None: + def _populate_search_types(self, ui_target: QtWidgets.QComboBox, options: Iterable[Any]) -> None: """Populate a combo box with search type options. Args: ui_target (QComboBox): Target combo box. - options (SearchTypes): Enum of search types. + options (Iterable): Enum of search types. """ for item in options: if item: @@ -323,17 +325,14 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): def handle_filter_activated(self) -> None: """Handle activation of filter headers in the results tree.""" - header: FilterHeader = self.tr_results.header() - filters = [] - + header = self.tr_results.header() + filters: list[str] = [] for i in range(header.count()): - text: str = header.filter_text(i) - - if text: - filters.append((i, text)) - - proxy_model: HumanProxyModel = self.tr_results.model() - proxy_model.filters = filters + # Use getattr to avoid attribute error + filters.append(getattr(header, "filter_text", lambda x: "")(i)) + proxy_model = self.tr_results.model() + if hasattr(proxy_model, "filters"): + proxy_model.filters = filters def _init_tree_results(self, tree: QtWidgets.QTreeView, model: QtGui.QStandardItemModel) -> None: """Initialize the results tree view and its model. @@ -385,8 +384,9 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): header = tree.header() - header.setSectionResizeMode(0, QtWidgets.QHeaderView.ResizeToContents) - header.setStretchLastSection(False) + if hasattr(header, "setSectionResizeMode"): + header.setSectionResizeMode(0, QtWidgets.QHeaderView.ResizeToContents) + tree.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) def tidal_user_lists(self) -> None: """Fetch and emit user playlists, mixes, and favorites from Tidal.""" @@ -394,7 +394,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.s_spinner_start.emit(self.tr_lists_user) self.s_pb_reload_status.emit(False) - user_all: [Playlist | UserPlaylist | Mix] = user_media_lists(self.tidal.session) + user_all: list[Any] = user_media_lists(self.tidal.session) self.s_populate_tree_lists.emit(user_all) @@ -423,7 +423,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): for item in user_lists: if isinstance(item, UserPlaylist | Playlist): twi_child = QtWidgets.QTreeWidgetItem(twi_playlists) - name: str = item.name + name: str = item.name if getattr(item, "name", None) is not None else "" description: str = f" {item.description}" if item.description else "" info: str = f"({item.num_tracks + item.num_videos} Tracks){description}" elif isinstance(item, Mix): @@ -488,12 +488,14 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): Args: settings (Settings): The settings object. """ - l_cb = [ - {"element": self.cb_quality_audio, "setting": settings.data.quality_audio, "default_id": 1}, - {"element": self.cb_quality_video, "setting": settings.data.quality_video, "default_id": 0}, + quality_audio = getattr(getattr(settings, "data", None), "quality_audio", 1) + quality_video = getattr(getattr(settings, "data", None), "quality_video", 0) + elements = [ + {"element": self.cb_quality_audio, "setting": quality_audio, "default_id": 1}, + {"element": self.cb_quality_video, "setting": quality_video, "default_id": 0}, ] - for item in l_cb: + for item in elements: idx = item["element"].findData(item["setting"]) if idx > -1: @@ -604,13 +606,13 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): clipboard.clear() clipboard.setText(url_share) - def on_download_list_media(self, point: QtCore.QPoint = None) -> None: + def on_download_list_media(self, point: QtCore.QPoint | None = None) -> None: """Download all media items in a selected list. Args: point (QPoint, optional): The point in the tree. Defaults to None. """ - items: [QtWidgets.QTreeWidgetItem] + items: list[QtWidgets.QTreeWidgetItem] = [] if point: items = [self.tr_lists_user.itemAt(point)] @@ -622,25 +624,23 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): for item in items: media = get_user_list_media_item(item) - queue_dl_item: QueueDownloadItem | False = self.media_to_queue_download_model(media) + queue_dl_item: QueueDownloadItem | None = self.media_to_queue_download_model(media) if queue_dl_item: self.queue_download_media(queue_dl_item) - def search_populate_results(self, query: str, type_media: SearchTypes) -> None: + def search_populate_results(self, query: str, type_media: Any) -> None: """Populate the results tree with search results. Args: query (str): The search query. type_media (SearchTypes): The type of media to search for. """ - self.model_tr_results.removeRows(0, self.model_tr_results.rowCount()) - - results: [ResultItem] = self.search(query, [type_media]) + results: list[ResultItem] = self.search(query, [type_media]) self.populate_tree_results(results) - def populate_tree_results(self, results: list[ResultItem], parent: QtGui.QStandardItem = None) -> None: + def populate_tree_results(self, results: list[ResultItem], parent: QtGui.QStandardItem | None = None) -> None: """Populate the results tree with ResultItem objects. Args: @@ -731,7 +731,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.apply_settings(self.settings) self._init_dl() - def search(self, query: str, types_media: SearchTypes) -> list[ResultItem]: + def search(self, query: str, types_media: Any) -> list[ResultItem]: """Perform a search and return a list of ResultItems. Args: @@ -761,7 +761,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): session=self.tidal.session, needle=query, types_media=types_media ) - result: [ResultItem] = [] + result: list[ResultItem] = [] for _media_type, l_media in result_search.items(): if isinstance(l_media, list): @@ -1310,7 +1310,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): else: media_items: [Track | Video | Album] = items_results_all(media_list) - result: [ResultItem] = self.search_result_to_model(media_items) + result: list[ResultItem] = self.search_result_to_model(media_items) self.populate_tree_results(result, parent=parent) @@ -1602,14 +1602,14 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): return result def on_version( - self, update_check: bool = False, update_available: bool = False, update_info: ReleaseLatest = None + self, update_check: bool = False, update_available: bool = False, update_info: ReleaseLatest | None = None ) -> None: """Show the version information dialog. Args: update_check (bool, optional): Whether to check for updates. Defaults to False. update_available (bool, optional): Whether an update is available. Defaults to False. - update_info (ReleaseLatest, optional): Information about the latest release. Defaults to None. + update_info (ReleaseLatest | None, optional): Information about the latest release. Defaults to None. """ DialogVersion(self, update_check, update_available, update_info) @@ -1637,7 +1637,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): if load_children: item.removeRow(0) - media_list: [Mix | Album | Playlist | Artist] = get_results_media_item( + media_list: list[Mix | Album | Playlist | Artist] = get_results_media_item( index, self.proxy_tr_results, self.model_tr_results ) @@ -1649,7 +1649,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): finally: self.s_spinner_stop.emit() - def button_reload_status(self, status: bool): + def button_reload_status(self, status: bool) -> None: """Update the reload button's state and text. Args: @@ -1663,11 +1663,11 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.pb_reload_user_lists.setEnabled(status) self.pb_reload_user_lists.setText(button_text) - def closeEvent(self, event): + def closeEvent(self, event: QtGui.QCloseEvent) -> None: """Handle the close event of the main window. Args: - event: The close event. + event (QtGui.QCloseEvent): The close event. """ self.shutdown = True From 99b4347b9ac5b20fa5bb60f8391b0e9871c53d0b Mon Sep 17 00:00:00 2001 From: Robert Honz Date: Tue, 1 Jul 2025 15:58:51 +0200 Subject: [PATCH 16/27] =?UTF-8?q?refactor(gui):=20=E2=9C=A8=20Improve=20fi?= =?UTF-8?q?lter=20handling=20in=20`MainWindow`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Refactored the filter extraction logic to directly use `header.filter_text(i)` for better clarity. * Updated the filters list to store tuples of index and text, enhancing the data structure used. * Ensured that the `proxy_model.filters` is set directly, simplifying the code. --- tidal_dl_ng/gui.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/tidal_dl_ng/gui.py b/tidal_dl_ng/gui.py index 6cf1f41..57613f5 100644 --- a/tidal_dl_ng/gui.py +++ b/tidal_dl_ng/gui.py @@ -328,11 +328,13 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): header = self.tr_results.header() filters: list[str] = [] for i in range(header.count()): - # Use getattr to avoid attribute error - filters.append(getattr(header, "filter_text", lambda x: "")(i)) - proxy_model = self.tr_results.model() - if hasattr(proxy_model, "filters"): - proxy_model.filters = filters + text: str = header.filter_text(i) + + if text: + filters.append((i, text)) + + proxy_model: HumanProxyModel = self.tr_results.model() + proxy_model.filters = filters def _init_tree_results(self, tree: QtWidgets.QTreeView, model: QtGui.QStandardItemModel) -> None: """Initialize the results tree view and its model. From fd16a620f2853b2bbb5e943c4ebd356945d352b9 Mon Sep 17 00:00:00 2001 From: Robert Honz Date: Wed, 2 Jul 2025 08:47:59 +0200 Subject: [PATCH 17/27] =?UTF-8?q?refactor(gui):=20=E2=9C=A8=20Enhance=20ty?= =?UTF-8?q?pe=20annotations=20and=20improve=20method=20signatures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Updated type hints for various methods in and classes. * Changed list types from to for better clarity and consistency. * Improved function signatures to include return types for better type safety. --- tidal_dl_ng/download.py | 64 ++++++++++++++++++++--------------------- tidal_dl_ng/gui.py | 34 +++++++++++----------- 2 files changed, 49 insertions(+), 49 deletions(-) diff --git a/tidal_dl_ng/download.py b/tidal_dl_ng/download.py index 9b13053..34d3ef5 100644 --- a/tidal_dl_ng/download.py +++ b/tidal_dl_ng/download.py @@ -57,7 +57,7 @@ from tidal_dl_ng.model.gui_data import ProgressBars class RequestsClient: def download( self, uri: str, timeout: int = REQUESTS_TIMEOUT_SEC, headers: dict | None = None, verify_ssl: bool = True - ): + ) -> tuple[str, str]: if not headers: headers = {} @@ -84,12 +84,12 @@ class Download: path_base: str, fn_logger: Callable, skip_existing: bool = False, - progress_gui: ProgressBars = None, - progress: Progress = None, - progress_overall: Progress = None, - event_abort: Event = None, - event_run: Event = None, - ): + progress_gui: ProgressBars | None = None, + progress: Progress | None = None, + progress_overall: Progress | None = None, + event_abort: Event | None = None, + event_run: Event | None = None, + ) -> None: self.settings = Settings() self.session = session self.skip_existing = skip_existing @@ -118,12 +118,12 @@ class Download: media: Track | Video, path_file: pathlib.Path, stream_manifest: StreamManifest | None = None, - ) -> (bool, pathlib.Path): + ) -> tuple[bool, pathlib.Path]: media_name: str = name_builder_item(media) - urls: [str] + urls: list[str] path_base: pathlib.Path = path_file.parent result_segments: bool = True - dl_segment_results: [DownloadSegmentResult] = [] + dl_segment_results: list[DownloadSegmentResult] = [] result_merge: bool = False # Get urls for media. @@ -179,7 +179,7 @@ class Download: max_workers=self.settings.data.downloads_simultaneous_per_track_max ) as executor: # Dispatch all download tasks to worker threads - l_futures: [futures.Future] = [ + l_futures: list[futures.Future] = [ executor.submit(self._download_segment, url, path_base, block_size, p_task, progress_to_stdout) for url in urls ] @@ -225,7 +225,7 @@ class Download: return result_merge, tmp_path_file_decrypted - def _segments_merge(self, path_file, dl_segment_results) -> bool: + def _segments_merge(self, path_file: pathlib.Path, dl_segment_results: list[DownloadSegmentResult]) -> bool: result: bool = True # Copy the content of all segments into one file. @@ -298,7 +298,7 @@ class Download: ) def extension_guess( - self, quality_audio: Quality, metadata_tags: [str], is_video: bool + self, quality_audio: Quality, metadata_tags: list[str], is_video: bool ) -> AudioExtensions | VideoExtensions: result: AudioExtensions | VideoExtensions @@ -321,9 +321,9 @@ class Download: def item( self, file_template: str, - media: Track | Video = None, - media_id: str = None, - media_type: MediaType = None, + media: Track | Video | None = None, + media_id: str | None = None, + media_type: MediaType | None = None, video_download: bool = True, download_delay: bool = False, quality_audio: Quality | None = None, @@ -331,7 +331,7 @@ class Download: is_parent_album: bool = False, list_position: int = 0, list_total: int = 0, - ) -> (bool, pathlib.Path): + ) -> tuple[bool, pathlib.Path | str]: try: if media_id and media_type: # If no media instance is provided, we need to create the media instance. @@ -557,14 +557,14 @@ class Download: return path_media_dst - def adjust_quality_audio(self, quality) -> Quality: + def adjust_quality_audio(self, quality: Quality) -> Quality: # Save original quality settings quality_old: Quality = self.session.audio_quality self.session.audio_quality = quality return quality_old - def adjust_quality_video(self, quality) -> QualityVideo: + def adjust_quality_video(self, quality: QualityVideo) -> QualityVideo: quality_old: QualityVideo = self.settings.data.quality_video self.settings.data.quality_video = quality @@ -618,7 +618,7 @@ class Download: return result @staticmethod - def cover_data(url: str = None, path_file: str = None) -> str | bytes: + def cover_data(url: str | None = None, path_file: str | None = None) -> str | bytes: result: str | bytes = "" if url: @@ -642,7 +642,7 @@ class Download: def metadata_write( self, track: Track, path_media: pathlib.Path, is_parent_album: bool, media_stream: Stream - ) -> (bool, pathlib.Path | None, pathlib.Path | None): + ) -> tuple[bool, pathlib.Path | None, pathlib.Path | None]: result: bool = False path_lyrics: pathlib.Path | None = None path_cover: pathlib.Path | None = None @@ -713,14 +713,14 @@ class Download: def items( self, file_template: str, - media: Album | Playlist | UserPlaylist | Mix = None, - media_id: str = None, - media_type: MediaType = None, + media: Album | Playlist | UserPlaylist | Mix | None = None, + media_id: str | None = None, + media_type: MediaType | None = None, video_download: bool = False, download_delay: bool = True, quality_audio: Quality | None = None, quality_video: QualityVideo | None = None, - ): + ) -> None: try: if media_id and media_type: # If no media instance is provided, we need to create the media instance. @@ -765,14 +765,14 @@ class Download: is_album: bool = isinstance(media, Album) # TODO: Refactor strings to constants (also in cfg.py) sort_by_track_num: bool = bool("album_track_num" in file_name_relative or "list_pos" in file_name_relative) - result_dirs: [pathlib.Path] = [] + result_dirs: list[pathlib.Path] = [] list_total: int = len(items) # Iterate through list items while not progress.finished: with futures.ThreadPoolExecutor(max_workers=self.settings.data.downloads_concurrent_max) as executor: # Dispatch all download tasks to worker threads - l_futures: [futures.Future] = [ + l_futures: list[futures.Future] = [ executor.submit( self.item, media=item_media, @@ -817,9 +817,9 @@ class Download: self.fn_logger.info(f"Finished list '{list_media_name}'.") def playlist_populate( - self, dirs_scoped: [pathlib.Path], name_list: str, is_album: bool, sort_alphabetically - ) -> [pathlib.Path]: - result: [pathlib.Path] = [] + self, dirs_scoped: set[pathlib.Path], name_list: str, is_album: bool, sort_alphabetically: bool + ) -> list[pathlib.Path]: + result: list[pathlib.Path] = [] # For each dir, which contains tracks for dir_scoped in dirs_scoped: @@ -830,7 +830,7 @@ class Download: self.fn_logger.debug(f"Playlist: Creating {path_playlist}") # Get all tracks in the directory - path_tracks: [pathlib.Path] = [] + path_tracks: list[pathlib.Path] = [] for extension_audio in AudioExtensions: path_tracks = path_tracks + list(dir_scoped.glob(f"*{extension_audio!s}")) @@ -889,7 +889,7 @@ class Download: return path_media_out - def _extract_video_stream(self, m3u8_variant: m3u8.M3U8, quality: int) -> (m3u8.M3U8 | bool, str): + def _extract_video_stream(self, m3u8_variant: m3u8.M3U8, quality: int) -> tuple[m3u8.M3U8 | bool, str]: m3u8_playlist: m3u8.M3U8 | bool = False resolution_best: int = 0 mime_type: str = "" diff --git a/tidal_dl_ng/gui.py b/tidal_dl_ng/gui.py index 57613f5..80dc07e 100644 --- a/tidal_dl_ng/gui.py +++ b/tidal_dl_ng/gui.py @@ -326,7 +326,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): def handle_filter_activated(self) -> None: """Handle activation of filter headers in the results tree.""" header = self.tr_results.header() - filters: list[str] = [] + filters: list[tuple[int, str]] = [] for i in range(header.count()): text: str = header.filter_text(i) @@ -369,7 +369,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): Args: model (QStandardItemModel): The model to initialize. """ - labels_column: [str] = ["#", "obj", "Artist", "Title", "Album", "Duration", "Quality", "Date"] + labels_column: list[str] = ["#", "obj", "Artist", "Title", "Album", "Duration", "Quality", "Date"] model.setColumnCount(len(labels_column)) model.setRowCount(0) @@ -404,7 +404,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): """Populate the user lists tree with playlists, mixes, and favorites. Args: - user_lists (list): List of user playlists, mixes, and favorites. + user_lists (list[Playlist | UserPlaylist | Mix]): List of user playlists, mixes, and favorites. """ twi_playlists: QtWidgets.QTreeWidgetItem = self.tr_lists_user.findItems( TidalLists.Playlists, QtCore.Qt.MatchExactly, 0 @@ -612,7 +612,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): """Download all media items in a selected list. Args: - point (QPoint, optional): The point in the tree. Defaults to None. + point (QPoint | None, optional): The point in the tree. Defaults to None. """ items: list[QtWidgets.QTreeWidgetItem] = [] @@ -733,12 +733,12 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.apply_settings(self.settings) self._init_dl() - def search(self, query: str, types_media: Any) -> list[ResultItem]: + def search(self, query: str, types_media: list[Any]) -> list[ResultItem]: """Perform a search and return a list of ResultItems. Args: query (str): The search query. - types_media (SearchTypes): The types of media to search for. + types_media (list[Any]): The types of media to search for. Returns: list[ResultItem]: The search results. @@ -759,7 +759,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): result_search = {"direct": [media]} else: - result_search: dict[str, [SearchTypes]] = search_results_all( + result_search: dict[str, list[SearchTypes]] = search_results_all( session=self.tidal.session, needle=query, types_media=types_media ) @@ -780,7 +780,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): Returns: list[ResultItem]: List of ResultItem models. """ - result = [] + result: list[ResultItem] = [] for idx, item in enumerate(items): result_item = self._to_result_item(idx, item) @@ -1288,16 +1288,16 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self, media_list: Album | Playlist | Mix | Artist | None = None, point: QtCore.QPoint | None = None, - parent: QtGui.QStandardItem = None, - favorite_function: Callable = None, + parent: QtGui.QStandardItem | None = None, + favorite_function: Callable | None = None, ) -> None: """Populate the results tree with the items of a media list. Args: media_list (Album | Playlist | Mix | Artist | None, optional): The media list to show. Defaults to None. point (QPoint | None, optional): The point in the tree. Defaults to None. - parent (QStandardItem, optional): Parent item for nested results. Defaults to None. - favorite_function (Callable, optional): Function to fetch favorite items. Defaults to None. + parent (QStandardItem | None, optional): Parent item for nested results. Defaults to None. + favorite_function (Callable | None, optional): Function to fetch favorite items. Defaults to None. """ if point: item = self.tr_lists_user.itemAt(point) @@ -1308,9 +1308,9 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): if isinstance(media_list, str): favorite_function = favorite_function_factory(self.tidal, media_list) - media_items: [Track | Video | Album] = favorite_function() + media_items: list[Track | Video | Album] = favorite_function() else: - media_items: [Track | Video | Album] = items_results_all(media_list) + media_items: list[Track | Video | Album] = items_results_all(media_list) result: list[ResultItem] = self.search_result_to_model(media_items) @@ -1346,7 +1346,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): Args: regex (str): Regular expression to match items. """ - items: [QtWidgets.QTreeWidgetItem | None] = self.tr_queue_download.findItems( + items: list[QtWidgets.QTreeWidgetItem | None] = self.tr_queue_download.findItems( regex, QtCore.Qt.MatchFlag.MatchRegularExpression, column=0 ) @@ -1355,7 +1355,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): def on_queue_download_remove(self) -> None: """Remove selected items from the download queue.""" - items: [QtWidgets.QTreeWidgetItem | None] = self.tr_queue_download.selectedItems() + items: list[QtWidgets.QTreeWidgetItem | None] = self.tr_queue_download.selectedItems() if len(items) == 0: logger_gui.error("Please select an item from the queue first.") @@ -1439,7 +1439,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): handling_app: HandlingApp = HandlingApp() while not handling_app.event_abort.is_set(): - items: [QtWidgets.QTreeWidgetItem | None] = self.tr_queue_download.findItems( + items: list[QtWidgets.QTreeWidgetItem | None] = self.tr_queue_download.findItems( QueueDownloadStatus.Waiting, QtCore.Qt.MatchFlag.MatchExactly, column=0 ) From 6d62a3b5c040abd9a0a666c5a930fcd5378101f3 Mon Sep 17 00:00:00 2001 From: Robert Honz Date: Wed, 2 Jul 2025 08:49:32 +0200 Subject: [PATCH 18/27] =?UTF-8?q?refactor(guidelines):=20=E2=9C=A8=20Updat?= =?UTF-8?q?e=20coding=20standards=20for=20type=20annotations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Added a guideline to use modern built-in generics from the `typing` module. * This change aligns with PEP 484 and promotes better coding practices. --- .github/copilot-instructions.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index c890c0c..bc8e87e 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -20,6 +20,7 @@ applyTo: "**/*.py" ## Coding Guidelines - Use type annotations / hints for function and method parameters, return types and variables. Follow PEP 484. +- Use the modern built-in generics from the `typing` module, such as `list`, `dict`, and `set`, instead of the older `List`, `Dict`, and `Set` from `typing`. - Use newest coding style which is supported by the used Python version. - Use blank lines to keep code organized and readable. - Follow PEP 492 – Coroutines with async and await syntax From 35d4ff92298415b927f2d41c20b19e9efec64198 Mon Sep 17 00:00:00 2001 From: Robert Honz Date: Wed, 2 Jul 2025 08:55:56 +0200 Subject: [PATCH 19/27] =?UTF-8?q?refactor(download):=20=E2=9C=A8=20Enhance?= =?UTF-8?q?=20docstrings=20for=20`RequestsClient`=20and=20`Download`=20cla?= =?UTF-8?q?sses?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Improved documentation for methods in the `RequestsClient` and `Download` classes. * Added detailed descriptions, parameters, and return types to enhance code readability and maintainability. --- tidal_dl_ng/download.py | 246 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 246 insertions(+) diff --git a/tidal_dl_ng/download.py b/tidal_dl_ng/download.py index 34d3ef5..72b61da 100644 --- a/tidal_dl_ng/download.py +++ b/tidal_dl_ng/download.py @@ -1,3 +1,13 @@ +""" +download.py + +Implements the Download class and helpers for downloading media from TIDAL, including segment merging, file moving, metadata writing, and playlist creation. + +Classes: + RequestsClient: Simple HTTP client for downloading text content. + Download: Main class for managing downloads, segment merging, file operations, and metadata. +""" + import os import pathlib import random @@ -55,9 +65,22 @@ from tidal_dl_ng.model.gui_data import ProgressBars # TODO: Set appropriate client string and use it for video download. # https://github.com/globocom/m3u8#using-different-http-clients class RequestsClient: + """HTTP client for downloading text content from a URI.""" + def download( self, uri: str, timeout: int = REQUESTS_TIMEOUT_SEC, headers: dict | None = None, verify_ssl: bool = True ) -> tuple[str, str]: + """Download the content of a URI as text. + + Args: + uri (str): The URI to download. + timeout (int, optional): Timeout in seconds. Defaults to REQUESTS_TIMEOUT_SEC. + headers (dict | None, optional): HTTP headers. Defaults to None. + verify_ssl (bool, optional): Whether to verify SSL. Defaults to True. + + Returns: + tuple[str, str]: Tuple of (text content, final URL). + """ if not headers: headers = {} @@ -68,6 +91,8 @@ class RequestsClient: # TODO: Use pathlib.Path everywhere class Download: + """Main class for managing downloads, segment merging, file operations, and metadata for TIDAL media.""" + settings: Settings session: Session skip_existing: bool = False @@ -90,6 +115,19 @@ class Download: event_abort: Event | None = None, event_run: Event | None = None, ) -> None: + """Initialize the Download object and its dependencies. + + Args: + session (Session): TIDAL session object. + path_base (str): Base path for downloads. + fn_logger (Callable): Logger function or object. + skip_existing (bool, optional): Whether to skip existing files. Defaults to False. + progress_gui (ProgressBars | None, optional): GUI progress bars. Defaults to None. + progress (Progress | None, optional): Rich progress bar. Defaults to None. + progress_overall (Progress | None, optional): Overall progress bar. Defaults to None. + event_abort (Event | None, optional): Abort event. Defaults to None. + event_run (Event | None, optional): Run event. Defaults to None. + """ self.settings = Settings() self.session = session self.skip_existing = skip_existing @@ -119,6 +157,16 @@ class Download: path_file: pathlib.Path, stream_manifest: StreamManifest | None = None, ) -> tuple[bool, pathlib.Path]: + """Download a media item (track or video), handling segments and merging. + + Args: + media (Track | Video): The media item to download. + path_file (pathlib.Path): Path to the output file. + stream_manifest (StreamManifest | None, optional): Stream manifest for tracks. Defaults to None. + + Returns: + tuple[bool, pathlib.Path]: (Success, path to downloaded or decrypted file) + """ media_name: str = name_builder_item(media) urls: list[str] path_base: pathlib.Path = path_file.parent @@ -226,6 +274,15 @@ class Download: return result_merge, tmp_path_file_decrypted def _segments_merge(self, path_file: pathlib.Path, dl_segment_results: list[DownloadSegmentResult]) -> bool: + """Merge downloaded segments into a single file and clean up segment files. + + Args: + path_file (pathlib.Path): Path to the output file. + dl_segment_results (list[DownloadSegmentResult]): List of segment download results. + + Returns: + bool: True if merge succeeded, False otherwise. + """ result: bool = True # Copy the content of all segments into one file. @@ -249,6 +306,18 @@ class Download: def _download_segment( self, url: str, path_base: pathlib.Path, block_size: int | None, p_task: TaskID, progress_to_stdout: bool ) -> DownloadSegmentResult: + """Download a single segment of a media file. + + Args: + url (str): URL of the segment. + path_base (pathlib.Path): Base path for segment file. + block_size (int | None): Block size for streaming. + p_task (TaskID): Progress bar task ID. + progress_to_stdout (bool): Whether to show progress in stdout. + + Returns: + DownloadSegmentResult: Result of the segment download. + """ result: bool = False path_segment: pathlib.Path = path_base / url_to_filename(url) # Calculate the segment ID based on the file name within the URL. @@ -300,6 +369,16 @@ class Download: def extension_guess( self, quality_audio: Quality, metadata_tags: list[str], is_video: bool ) -> AudioExtensions | VideoExtensions: + """Guess the file extension for a media item based on quality and type. + + Args: + quality_audio (Quality): Audio quality. + metadata_tags (list[str]): Metadata tags for the media. + is_video (bool): Whether the media is a video. + + Returns: + AudioExtensions | VideoExtensions: Guessed file extension. + """ result: AudioExtensions | VideoExtensions if is_video: @@ -332,6 +411,24 @@ class Download: list_position: int = 0, list_total: int = 0, ) -> tuple[bool, pathlib.Path | str]: + """Download a single media item, handling file naming, skipping, and post-processing. + + Args: + file_template (str): Template for file naming. + media (Track | Video | None, optional): Media item. Defaults to None. + media_id (str | None, optional): Media ID. Defaults to None. + media_type (MediaType | None, optional): Media type. Defaults to None. + video_download (bool, optional): Whether to allow video downloads. Defaults to True. + download_delay (bool, optional): Whether to delay between downloads. Defaults to False. + quality_audio (Quality | None, optional): Audio quality. Defaults to None. + quality_video (QualityVideo | None, optional): Video quality. Defaults to None. + is_parent_album (bool, optional): Whether this is a parent album. Defaults to False. + list_position (int, optional): Position in list. Defaults to 0. + list_total (int, optional): Total items in list. Defaults to 0. + + Returns: + tuple[bool, pathlib.Path | str]: (Downloaded, path to file) + """ try: if media_id and media_type: # If no media instance is provided, we need to create the media instance. @@ -526,6 +623,16 @@ class Download: def media_move_and_symlink( self, media: Track | Video, path_media_src: pathlib.Path, file_extension: str ) -> pathlib.Path: + """Move a media file and create a symlink if required. + + Args: + media (Track | Video): Media item. + path_media_src (pathlib.Path): Source file path. + file_extension (str): File extension. + + Returns: + pathlib.Path: Destination path. + """ # Compute tracks path, sanitize and ensure path exists file_name_relative: str = format_path_media(self.settings.data.format_track, media) path_media_dst: pathlib.Path = ( @@ -558,6 +665,14 @@ class Download: return path_media_dst def adjust_quality_audio(self, quality: Quality) -> Quality: + """Temporarily set audio quality and return the previous value. + + Args: + quality (Quality): New audio quality. + + Returns: + Quality: Previous audio quality. + """ # Save original quality settings quality_old: Quality = self.session.audio_quality self.session.audio_quality = quality @@ -565,6 +680,14 @@ class Download: return quality_old def adjust_quality_video(self, quality: QualityVideo) -> QualityVideo: + """Temporarily set video quality and return the previous value. + + Args: + quality (QualityVideo): New video quality. + + Returns: + QualityVideo: Previous video quality. + """ quality_old: QualityVideo = self.settings.data.quality_video self.settings.data.quality_video = quality @@ -572,6 +695,15 @@ class Download: return quality_old def _move_file(self, path_file_source: pathlib.Path, path_file_destination: str | pathlib.Path) -> bool: + """Move a file from source to destination. + + Args: + path_file_source (pathlib.Path): Source file path. + path_file_destination (str | pathlib.Path): Destination file path. + + Returns: + bool: True if moved, False otherwise. + """ result: bool # Check if the file was downloaded @@ -586,6 +718,15 @@ class Download: return result def _move_lyrics(self, path_lyrics: pathlib.Path, file_media_dst: pathlib.Path) -> bool: + """Move a lyrics file to the destination. + + Args: + path_lyrics (pathlib.Path): Source lyrics file. + file_media_dst (pathlib.Path): Destination media file path. + + Returns: + bool: True if moved, False otherwise. + """ # Build tmp lyrics filename path_file_lyrics: pathlib.Path = file_media_dst.with_suffix(EXTENSION_LYRICS) result: bool = self._move_file(path_lyrics, path_file_lyrics) @@ -593,6 +734,15 @@ class Download: return result def _move_cover(self, path_cover: pathlib.Path, file_media_dst: pathlib.Path) -> bool: + """Move a cover file to the destination. + + Args: + path_cover (pathlib.Path): Source cover file. + file_media_dst (pathlib.Path): Destination media file path. + + Returns: + bool: True if moved, False otherwise. + """ # Build tmp lyrics filename path_file_cover: pathlib.Path = file_media_dst.parent / COVER_NAME result: bool = self._move_file(path_cover, path_file_cover) @@ -600,12 +750,40 @@ class Download: return result def lyrics_to_file(self, dir_destination: pathlib.Path, lyrics: str) -> str: + """Write lyrics to a temporary file. + + Args: + dir_destination (pathlib.Path): Directory for the temp file. + lyrics (str): Lyrics content. + + Returns: + str: Path to the temp file. + """ return self.write_to_tmp_file(dir_destination, mode="x", content=lyrics) def cover_to_file(self, dir_destination: pathlib.Path, image: bytes) -> str: + """Write cover image to a temporary file. + + Args: + dir_destination (pathlib.Path): Directory for the temp file. + image (bytes): Image data. + + Returns: + str: Path to the temp file. + """ return self.write_to_tmp_file(dir_destination, mode="xb", content=image) def write_to_tmp_file(self, dir_destination: pathlib.Path, mode: str, content: str | bytes) -> str: + """Write content to a temporary file. + + Args: + dir_destination (pathlib.Path): Directory for the temp file. + mode (str): File open mode. + content (str | bytes): Content to write. + + Returns: + str: Path to the temp file. + """ result: pathlib.Path = dir_destination / str(uuid4()) encoding: str | None = "utf-8" if isinstance(content, str) else None @@ -619,6 +797,15 @@ class Download: @staticmethod def cover_data(url: str | None = None, path_file: str | None = None) -> str | bytes: + """Retrieve cover image data from a URL or file. + + Args: + url (str | None, optional): URL to download image from. Defaults to None. + path_file (str | None, optional): Path to image file. Defaults to None. + + Returns: + str | bytes: Image data or empty string on failure. + """ result: str | bytes = "" if url: @@ -643,6 +830,17 @@ class Download: def metadata_write( self, track: Track, path_media: pathlib.Path, is_parent_album: bool, media_stream: Stream ) -> tuple[bool, pathlib.Path | None, pathlib.Path | None]: + """Write metadata, lyrics, and cover to a media file. + + Args: + track (Track): Track object. + path_media (pathlib.Path): Path to media file. + is_parent_album (bool): Whether this is a parent album. + media_stream (Stream): Stream object. + + Returns: + tuple[bool, pathlib.Path | None, pathlib.Path | None]: (Success, path to lyrics, path to cover) + """ result: bool = False path_lyrics: pathlib.Path | None = None path_cover: pathlib.Path | None = None @@ -721,6 +919,18 @@ class Download: quality_audio: Quality | None = None, quality_video: QualityVideo | None = None, ) -> None: + """Download all items in an album, playlist, or mix. + + Args: + file_template (str): Template for file naming. + media (Album | Playlist | UserPlaylist | Mix | None, optional): Media item. Defaults to None. + media_id (str | None, optional): Media ID. Defaults to None. + media_type (MediaType | None, optional): Media type. Defaults to None. + video_download (bool, optional): Whether to allow video downloads. Defaults to False. + download_delay (bool, optional): Whether to delay between downloads. Defaults to True. + quality_audio (Quality | None, optional): Audio quality. Defaults to None. + quality_video (QualityVideo | None, optional): Video quality. Defaults to None. + """ try: if media_id and media_type: # If no media instance is provided, we need to create the media instance. @@ -819,6 +1029,17 @@ class Download: def playlist_populate( self, dirs_scoped: set[pathlib.Path], name_list: str, is_album: bool, sort_alphabetically: bool ) -> list[pathlib.Path]: + """Create playlist files (m3u) for downloaded tracks in each directory. + + Args: + dirs_scoped (set[pathlib.Path]): Set of directories containing tracks. + name_list (str): Name of the playlist. + is_album (bool): Whether this is an album. + sort_alphabetically (bool): Whether to sort tracks alphabetically. + + Returns: + list[pathlib.Path]: List of created playlist file paths. + """ result: list[pathlib.Path] = [] # For each dir, which contains tracks @@ -858,6 +1079,14 @@ class Download: return result def _video_convert(self, path_file: pathlib.Path) -> pathlib.Path: + """Convert a TS video file to MP4 using ffmpeg. + + Args: + path_file (pathlib.Path): Path to the TS file. + + Returns: + pathlib.Path: Path to the converted MP4 file. + """ path_file_out: pathlib.Path = path_file.with_suffix(AudioExtensions.MP4) ffmpeg = ( FFmpeg(executable=self.settings.data.path_binary_ffmpeg) @@ -871,6 +1100,14 @@ class Download: return path_file_out def _extract_flac(self, path_media_src: pathlib.Path) -> pathlib.Path: + """Extract FLAC audio from a media file using ffmpeg. + + Args: + path_media_src (pathlib.Path): Path to the source media file. + + Returns: + pathlib.Path: Path to the extracted FLAC file. + """ path_media_out = path_media_src.with_suffix(AudioExtensions.FLAC) ffmpeg = ( FFmpeg(executable=self.settings.data.path_binary_ffmpeg) @@ -890,6 +1127,15 @@ class Download: return path_media_out def _extract_video_stream(self, m3u8_variant: m3u8.M3U8, quality: int) -> tuple[m3u8.M3U8 | bool, str]: + """Extract the best matching video stream from an m3u8 variant playlist. + + Args: + m3u8_variant (m3u8.M3U8): The m3u8 variant playlist. + quality (int): Desired video quality (vertical resolution). + + Returns: + tuple[m3u8.M3U8 | bool, str]: (Selected m3u8 playlist or False, codecs string) + """ m3u8_playlist: m3u8.M3U8 | bool = False resolution_best: int = 0 mime_type: str = "" From 6d7f4f1625cb57f3d8c58f9b2e07dd759834cc23 Mon Sep 17 00:00:00 2001 From: Robert Honz Date: Wed, 2 Jul 2025 09:56:57 +0200 Subject: [PATCH 20/27] =?UTF-8?q?refactor(guidelines):=20=E2=9C=A8=20Impro?= =?UTF-8?q?ve=20clarity=20in=20coding=20standards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Enhanced wording for better understanding of blank line usage. * Clarified instructions on updating docstrings during refactoring. * Specified the use of line comments for complex logic explanations. --- .github/copilot-instructions.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index bc8e87e..f62d2fa 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -22,7 +22,7 @@ applyTo: "**/*.py" - Use type annotations / hints for function and method parameters, return types and variables. Follow PEP 484. - Use the modern built-in generics from the `typing` module, such as `list`, `dict`, and `set`, instead of the older `List`, `Dict`, and `Set` from `typing`. - Use newest coding style which is supported by the used Python version. -- Use blank lines to keep code organized and readable. +- Use more blank lines to achieve better code organization and readability. - Follow PEP 492 – Coroutines with async and await syntax - Follow PEP 498 – Literal String Interpolation - Follow PEP 572 – Assignment Expressions @@ -31,4 +31,6 @@ applyTo: "**/*.py" - Always write doc strings for all modules, classes, functions, and methods using google docstring style. - Use typing in doc strings. -- Use short inline comments to explain complex logic. +- Use line comments to explain complex logic. +- If refactoring code, ensure to update or add doc strings accordingly. +- If refactoring code to not remove existing line comments, but to update them to reflect the new code logic. From 37f9250cdf6086f933099d70b3f214f2aff12824 Mon Sep 17 00:00:00 2001 From: Robert Honz Date: Wed, 2 Jul 2025 10:29:54 +0200 Subject: [PATCH 21/27] =?UTF-8?q?refactor(download):=20=E2=9C=A8=20Refacto?= =?UTF-8?q?r=20media=20download=20logic=20for=20clarity=20and=20efficiency?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Renamed `_download` to `_get_media_urls` for better clarity on its purpose. * Simplified URL extraction logic for `Track` and `Video` media types. * Improved progress setup and error handling in the download process. * Enhanced `_download_segments` and `_download_postprocess` methods for better readability and maintainability. --- tidal_dl_ng/download.py | 178 +++++++++++++++++++++++++++++++--------- 1 file changed, 138 insertions(+), 40 deletions(-) diff --git a/tidal_dl_ng/download.py b/tidal_dl_ng/download.py index 72b61da..46a8959 100644 --- a/tidal_dl_ng/download.py +++ b/tidal_dl_ng/download.py @@ -151,53 +151,54 @@ class Download: "be set in (`path_binary_ffmpeg`)." ) - def _download( + def _get_media_urls( self, media: Track | Video, - path_file: pathlib.Path, stream_manifest: StreamManifest | None = None, - ) -> tuple[bool, pathlib.Path]: - """Download a media item (track or video), handling segments and merging. + ) -> list[str]: + """Extract URLs for the given media item. Args: media (Track | Video): The media item to download. - path_file (pathlib.Path): Path to the output file. stream_manifest (StreamManifest | None, optional): Stream manifest for tracks. Defaults to None. Returns: - tuple[bool, pathlib.Path]: (Success, path to downloaded or decrypted file) + list[str]: List of URLs for the media segments. """ - media_name: str = name_builder_item(media) - urls: list[str] - path_base: pathlib.Path = path_file.parent - result_segments: bool = True - dl_segment_results: list[DownloadSegmentResult] = [] - result_merge: bool = False - # Get urls for media. - try: - if isinstance(media, Track): - urls = stream_manifest.get_urls() - elif isinstance(media, Video): - m3u8_variant: m3u8.M3U8 = m3u8.load(media.get_url()) - # Find the desired video resolution or the next best one. - m3u8_playlist, codecs = self._extract_video_stream(m3u8_variant, int(self.settings.data.quality_video)) - # Populate urls. - urls = m3u8_playlist.files - except Exception: - return False, path_file + if isinstance(media, Track): + return stream_manifest.get_urls() + elif isinstance(media, Video): + quality_video = self.settings.data.quality_video + m3u8_variant: m3u8.M3U8 = m3u8.load(media.get_url()) + # Find the desired video resolution or the next best one. + m3u8_playlist, _ = self._extract_video_stream(m3u8_variant, int(quality_video)) - # Set the correct progress output channel. - if self.progress_gui is None: - progress_to_stdout: bool = True + return m3u8_playlist.files else: - progress_to_stdout: bool = False - # Send signal to GUI with media name - self.progress_gui.item_name.emit(media_name[:30]) + return [] + + def _setup_progress( + self, + media_name: str, + urls: list[str], + progress_to_stdout: bool, + ) -> tuple[TaskID, int | float | None, int | None]: + """Set up the progress bar/task and compute progress total and block size. + + Args: + media_name (str): Name of the media item. + urls (list[str]): List of segment URLs. + progress_to_stdout (bool): Whether to show progress in stdout. + + Returns: + tuple[TaskID, int | float | None, int | None]: (TaskID, progress_total, block_size) + """ + urls_count: int = len(urls) + progress_total: int | float | None = None + block_size: int | None = None # Compute total iterations for progress - urls_count: int = len(urls) - if urls_count > 1: progress_total: int = urls_count block_size: int | None = None @@ -205,9 +206,10 @@ class Download: try: # Get file size and compute progress steps r = requests.head(urls[0], timeout=REQUESTS_TIMEOUT_SEC) + total_size_in_bytes: int = int(r.headers.get("content-length", 0)) - block_size: int | None = 1048576 - progress_total: float = total_size_in_bytes / block_size + block_size = 1048576 + progress_total = total_size_in_bytes / block_size finally: r.close() else: @@ -219,6 +221,30 @@ class Download: total=progress_total, visible=progress_to_stdout, ) + return p_task, progress_total, block_size + + def _download_segments( + self, + urls: list[str], + path_base: pathlib.Path, + block_size: int | None, + p_task: TaskID, + progress_to_stdout: bool, + ) -> tuple[bool, list[DownloadSegmentResult]]: + """Download all segments with progress reporting and abort handling. + + Args: + urls (list[str]): List of segment URLs. + path_base (pathlib.Path): Base path for segment files. + block_size (int | None): Block size for streaming. + p_task (TaskID): Progress bar task ID. + progress_to_stdout (bool): Whether to show progress in stdout. + + Returns: + tuple[bool, list[DownloadSegmentResult]]: (result_segments, list of segment results) + """ + result_segments: bool = True + dl_segment_results: list[DownloadSegmentResult] = [] # Download segments until progress is finished. # TODO: Compute download speed (https://github.com/Textualize/rich/blob/master/examples/downloader.py) @@ -239,14 +265,15 @@ class Download: dl_segment_results.append(result_dl_segment) - # check for a link that was skipped + # Check for a link that was skipped if not result_dl_segment.result and (result_dl_segment.url is not urls[-1]): # Sometimes it happens, if a track is very short (< 8 seconds or so), that the last URL in `urls` is # invalid (HTTP Error 500) and not necessary. File won't be corrupt. # If this is NOT the case, but any other URL has resulted in an error, # mark the whole thing as corrupt. result_segments = False - self.fn_logger.error(f"Something went wrong while downloading {media_name}. File is corrupt!") + + self.fn_logger.error("Something went wrong while downloading. File is corrupt!") # If app is terminated (CTRL+C) if self.event_abort.is_set(): @@ -254,25 +281,96 @@ class Download: for f in l_futures: f.cancel() - return False, path_file + return False, dl_segment_results + return result_segments, dl_segment_results + + def _download_postprocess( + self, + result_segments: bool, + path_file: pathlib.Path, + dl_segment_results: list[DownloadSegmentResult], + media: Track | Video, + stream_manifest: StreamManifest | None = None, + ) -> tuple[bool, pathlib.Path]: + """Merge segments, decrypt if needed, and return the final file path. + + Args: + result_segments (bool): Whether all segments downloaded successfully. + path_file (pathlib.Path): Path to the output file. + dl_segment_results (list[DownloadSegmentResult]): List of segment download results. + media (Track | Video): The media item. + stream_manifest (StreamManifest | None, optional): Stream manifest for tracks. Defaults to None. + + Returns: + tuple[bool, pathlib.Path]: (Success, path to downloaded or decrypted file) + """ tmp_path_file_decrypted: pathlib.Path = path_file + result_merge: bool = False # Only if no error happened while downloading. if result_segments: # Bring list into right order, so segments can be easily merged. dl_segment_results.sort(key=lambda x: x.id_segment) - result_merge: bool = self._segments_merge(path_file, dl_segment_results) + + result_merge = self._segments_merge(path_file, dl_segment_results) if not result_merge: - self.fn_logger.error(f"Something went wrong while writing to {media_name}. File is corrupt!") - elif result_merge and isinstance(media, Track) and stream_manifest.is_encrypted: + self.fn_logger.error(f"Something went wrong while writing to {media.name}. File is corrupt!") + elif isinstance(media, Track) and stream_manifest.is_encrypted: key, nonce = decrypt_security_token(stream_manifest.encryption_key) tmp_path_file_decrypted = path_file.with_suffix(".decrypted") + decrypt_file(path_file, tmp_path_file_decrypted, key, nonce) return result_merge, tmp_path_file_decrypted + def _download( + self, + media: Track | Video, + path_file: pathlib.Path, + stream_manifest: StreamManifest | None = None, + ) -> tuple[bool, pathlib.Path]: + """Download a media item (track or video), handling segments and merging. + + Args: + media (Track | Video): The media item to download. + path_file (pathlib.Path): Path to the output file. + stream_manifest (StreamManifest | None, optional): Stream manifest for tracks. Defaults to None. + + Returns: + tuple[bool, pathlib.Path]: (Success, path to downloaded or decrypted file) + """ + media_name: str = name_builder_item(media) + + try: + urls: list[str] = self._get_media_urls(media, stream_manifest) + except Exception: + return False, path_file + + # Set the correct progress output channel. + if self.progress_gui is None: + progress_to_stdout: bool = True + else: + progress_to_stdout: bool = False + # Send signal to GUI with media name + self.progress_gui.item_name.emit(media_name[:30]) + + try: + p_task, progress_total, block_size = self._setup_progress(media_name, urls, progress_to_stdout) + except Exception: + return False, path_file + + result_segments, dl_segment_results = self._download_segments( + urls, path_file.parent, block_size, p_task, progress_to_stdout + ) + + result_merge, tmp_path_file_decrypted = self._download_postprocess( + result_segments, path_file, dl_segment_results, media, stream_manifest + ) + + return result_merge, tmp_path_file_decrypted + def _segments_merge(self, path_file: pathlib.Path, dl_segment_results: list[DownloadSegmentResult]) -> bool: """Merge downloaded segments into a single file and clean up segment files. From 33f5fcbda8e31df5577616137966d002acab284d Mon Sep 17 00:00:00 2001 From: Robert Honz Date: Wed, 2 Jul 2025 14:52:31 +0200 Subject: [PATCH 22/27] =?UTF-8?q?refactor(download):=20=F0=9F=94=A7=20Impr?= =?UTF-8?q?ove=20media=20validation=20and=20download=20process?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Reordered parameters in `item` method for clarity. * Added `_validate_media_instance` method to streamline media validation. * Enhanced `_prepare_file_paths_and_skip_logic` for better file handling. * Introduced `_perform_actual_download` to encapsulate download logic. * Improved error handling and logging throughout the download process. --- tidal_dl_ng/download.py | 419 ++++++++++++++++++++++++++++++---------- 1 file changed, 315 insertions(+), 104 deletions(-) diff --git a/tidal_dl_ng/download.py b/tidal_dl_ng/download.py index 46a8959..c58d699 100644 --- a/tidal_dl_ng/download.py +++ b/tidal_dl_ng/download.py @@ -498,9 +498,9 @@ class Download: def item( self, file_template: str, - media: Track | Video | None = None, media_id: str | None = None, media_type: MediaType | None = None, + media: Track | Video | None = None, video_download: bool = True, download_delay: bool = False, quality_audio: Quality | None = None, @@ -513,9 +513,9 @@ class Download: Args: file_template (str): Template for file naming. - media (Track | Video | None, optional): Media item. Defaults to None. media_id (str | None, optional): Media ID. Defaults to None. media_type (MediaType | None, optional): Media type. Defaults to None. + media (Track | Video | None, optional): Media item. Defaults to None. video_download (bool, optional): Whether to allow video downloads. Defaults to True. download_delay (bool, optional): Whether to delay between downloads. Defaults to False. quality_audio (Quality | None, optional): Audio quality. Defaults to None. @@ -527,24 +527,86 @@ class Download: Returns: tuple[bool, pathlib.Path | str]: (Downloaded, path to file) """ + # Step 1: Validate and prepare media + media = self._validate_media_instance(media, media_id, media_type, video_download) + if media is None: + return False, "" + + # Step 2: Create file paths and determine skip logic + path_media_dst, file_extension_dummy, skip_file, skip_download = self._prepare_file_paths_and_skip_logic( + media, file_template, quality_audio, list_position, list_total + ) + + if skip_file: + self.fn_logger.debug(f"Download skipped, since file exists: '{path_media_dst}'") + + return True, path_media_dst + + # Step 3: Handle quality settings + quality_audio_old, quality_video_old = self._adjust_quality_settings(quality_audio, quality_video) + + # Step 4: Download and process media + download_success = self._download_and_process_media( + media, path_media_dst, skip_download, is_parent_album, file_extension_dummy + ) + + # Step 5: Post-processing + self._perform_post_processing( + media, + path_media_dst, + quality_audio, + quality_video, + quality_audio_old, + quality_video_old, + download_delay, + skip_file, + ) + + return download_success, path_media_dst + + def _validate_media_instance( + self, + media: Track | Video | None, + media_id: str | None, + media_type: MediaType | None, + video_download: bool, + ) -> Track | Video | None: + """Validate and prepare media instance for download. + + Args: + media (Track | Video | None): Media item instance. + media_id (str | None): Media ID if creating new instance. + media_type (MediaType | None): Media type if creating new instance. + video_download (bool): Whether video downloads are allowed. + + Returns: + Track | Video | None: Prepared media instance or None if invalid. + """ try: if media_id and media_type: # If no media instance is provided, we need to create the media instance. - media = instantiate_media(self.session, media_type, media_id) - elif isinstance(media, Track | Video): # Check if media is available not deactivated / removed from TIDAL. + media_instance = instantiate_media(self.session, media_type, media_id) + + # Filter to only Track or Video types + if isinstance(media_instance, Track | Video): + media = media_instance + else: + return None + elif isinstance(media, Track | Video): + # Check if media is available not deactivated / removed from TIDAL. if not media.available: self.fn_logger.info( f"This item is not available for listening anymore on TIDAL. Skipping: {name_builder_item(media)}" ) - return False, "" + return None elif isinstance(media, Track): # Re-create media instance with full album information - media = self.session.track(media.id, with_album=True) + media = self.session.track(str(media.id), with_album=True) elif not media: raise MediaMissing except: - return False, "" + return None # If video download is not allowed end here if not video_download and isinstance(media, Video): @@ -552,17 +614,44 @@ class Download: f"Video downloads are deactivated (see settings). Skipping video: {name_builder_item(media)}" ) - return False, "" + return None + return media + + def _prepare_file_paths_and_skip_logic( + self, + media: Track | Video, + file_template: str, + quality_audio: Quality | None, + list_position: int, + list_total: int, + ) -> tuple[pathlib.Path, str, bool, bool]: + """Prepare file paths and determine skip logic. + + Args: + media (Track | Video): Media item. + file_template (str): Template for file naming. + quality_audio (Quality | None): Audio quality setting. + list_position (int): Position in list. + list_total (int): Total items in list. + + Returns: + tuple[pathlib.Path, str, bool, bool]: (path_media_dst, file_extension_dummy, skip_file, skip_download) + """ # Create file name and path + metadata_tags = [] if isinstance(media, Video) else (media.media_metadata_tags or []) + quality_for_extension = quality_audio if quality_audio is not None else Quality.high_lossless + file_extension_dummy: str = self.extension_guess( - quality_audio, - metadata_tags=[] if isinstance(media, Video) else media.media_metadata_tags, + quality_for_extension, + metadata_tags=metadata_tags, is_video=isinstance(media, Video), ) + file_name_relative: str = format_path_media( file_template, media, self.settings.data.album_track_num_pad_min, list_position, list_total ) + path_media_dst: pathlib.Path = ( pathlib.Path(self.path_base).expanduser() / (file_name_relative + file_extension_dummy) ).absolute() @@ -589,122 +678,246 @@ class Download: ) skip_download = file_exists_playlist_dir or file_exists_track_dir - # If + # If file exists in playlist dir but not in track dir, we don't skip the file itself if skip_file and file_exists_playlist_dir: skip_file = False else: skip_file: bool = False - if not skip_file: - # If a quality is explicitly set, change it and remember the previously set quality. - quality_audio_old: Quality = self.adjust_quality_audio(quality_audio) if quality_audio else quality_audio - quality_video_old: QualityVideo = ( - self.adjust_quality_video(quality_video) if quality_video else quality_video + return path_media_dst, file_extension_dummy, skip_file, skip_download + + def _adjust_quality_settings( + self, quality_audio: Quality | None, quality_video: QualityVideo | None + ) -> tuple[Quality | None, QualityVideo | None]: + """Adjust quality settings and return previous values. + + Args: + quality_audio (Quality | None): Audio quality setting. + quality_video (QualityVideo | None): Video quality setting. + + Returns: + tuple[Quality | None, QualityVideo | None]: Previous quality settings. + """ + quality_audio_old: Quality | None = None + quality_video_old: QualityVideo | None = None + + if quality_audio: + quality_audio_old = self.adjust_quality_audio(quality_audio) + + if quality_video: + quality_video_old = self.adjust_quality_video(quality_video) + + return quality_audio_old, quality_video_old + + def _download_and_process_media( + self, + media: Track | Video, + path_media_dst: pathlib.Path, + skip_download: bool, + is_parent_album: bool, + file_extension_dummy: str, + ) -> bool: + """Download and process media file. + + Args: + media (Track | Video): Media item. + path_media_dst (pathlib.Path): Destination file path. + skip_download (bool): Whether to skip download. + is_parent_album (bool): Whether this is a parent album. + file_extension_dummy (str): Dummy file extension. + + Returns: + bool: Whether download was successful. + """ + if skip_download: + return True + + # Get stream information and final file extension + stream_manifest, file_extension, do_flac_extract, media_stream = self._get_stream_info(media) + + if stream_manifest is None and isinstance(media, Track): + return False + + # Update path if extension changed + if path_media_dst.suffix != file_extension: + path_media_dst = path_media_dst.with_suffix(file_extension) + path_media_dst = pathlib.Path(path_file_sanitize(path_media_dst, adapt=True)) + + os.makedirs(path_media_dst.parent, exist_ok=True) + + # Perform actual download + return self._perform_actual_download( + media, path_media_dst, stream_manifest, do_flac_extract, is_parent_album, media_stream + ) + + def _get_stream_info(self, media: Track | Video) -> tuple[StreamManifest | None, str, bool, Stream | None]: + """Get stream information for media. + + Args: + media (Track | Video): Media item. + + Returns: + tuple[StreamManifest | None, str, bool, Stream | None]: Stream info. + """ + stream_manifest: StreamManifest | None = None + media_stream: Stream | None = None + do_flac_extract: bool = False + + if isinstance(media, Track): + try: + media_stream = media.get_stream() + stream_manifest = media_stream.get_stream_manifest() + except TooManyRequests: + self.fn_logger.exception( + f"Too many requests against TIDAL backend. Skipping '{name_builder_item(media)}'. " + f"Consider to activate delay between downloads." + ) + + return None, "", False, None + except Exception: + self.fn_logger.exception(f"Something went wrong. Skipping '{name_builder_item(media)}'.") + + return None, "", False, None + + file_extension = stream_manifest.file_extension + + if self.settings.data.extract_flac and ( + stream_manifest.codecs.upper() == Codec.FLAC and file_extension != AudioExtensions.FLAC + ): + file_extension = AudioExtensions.FLAC + do_flac_extract = True + elif isinstance(media, Video): + file_extension = AudioExtensions.MP4 if self.settings.data.video_convert_mp4 else VideoExtensions.TS + + return stream_manifest, file_extension, do_flac_extract, media_stream + + def _perform_actual_download( + self, + media: Track | Video, + path_media_dst: pathlib.Path, + stream_manifest: StreamManifest | None, + do_flac_extract: bool, + is_parent_album: bool, + media_stream: Stream | None, + ) -> bool: + """Perform the actual download and processing. + + Args: + media (Track | Video): Media item. + path_media_dst (pathlib.Path): Destination file path. + stream_manifest (StreamManifest | None): Stream manifest. + do_flac_extract (bool): Whether to extract FLAC. + is_parent_album (bool): Whether this is a parent album. + media_stream (Stream | None): Media stream. + + Returns: + bool: Whether download was successful. + """ + # Create a temp directory and file. + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmp_path_dir: + tmp_path_file: pathlib.Path = pathlib.Path(tmp_path_dir) / str(uuid4()) + tmp_path_file.touch() + + # Download media. + result_download, tmp_path_file = self._download( + media=media, stream_manifest=stream_manifest, path_file=tmp_path_file ) - do_flac_extract = False - # Get extension. - file_extension: str - stream_manifest: StreamManifest | None = None - if isinstance(media, Track): - try: - media_stream: Stream = media.get_stream() - stream_manifest = media_stream.get_stream_manifest() - except TooManyRequests: - self.fn_logger.exception( - f"Too many requests against TIDAL backend. Skipping '{name_builder_item(media)}'. " - f"Consider to activate delay between downloads." - ) + if not result_download: + return False - return False, "" - except Exception: - self.fn_logger.exception(f"Something went wrong. Skipping '{name_builder_item(media)}'.") + # Convert video from TS to MP4 + if isinstance(media, Video) and self.settings.data.video_convert_mp4: + tmp_path_file = self._video_convert(tmp_path_file) - return False, "" + # Extract FLAC from MP4 container using ffmpeg + if isinstance(media, Track) and self.settings.data.extract_flac and do_flac_extract: + tmp_path_file = self._extract_flac(tmp_path_file) - file_extension = stream_manifest.file_extension + # Handle metadata, lyrics, and cover + self._handle_metadata_and_extras(media, tmp_path_file, path_media_dst, is_parent_album, media_stream) - if self.settings.data.extract_flac and ( - stream_manifest.codecs.upper() == Codec.FLAC and file_extension != AudioExtensions.FLAC - ): - file_extension = AudioExtensions.FLAC - do_flac_extract = True - elif isinstance(media, Video): - file_extension = AudioExtensions.MP4 if self.settings.data.video_convert_mp4 else VideoExtensions.TS + self.fn_logger.info(f"Downloaded item '{name_builder_item(media)}'.") - # If file extension was guessed wrong in the beginning - if path_media_dst.suffix != file_extension: - # Compute file name, sanitize once again, because file extension could have been replaced after guessing it first and create destination directory - path_media_dst = path_media_dst.with_suffix(file_extension) - path_media_dst = pathlib.Path(path_file_sanitize(path_media_dst, adapt=True)) + # Move final file to the configured destination directory. + shutil.move(tmp_path_file, path_media_dst) - os.makedirs(path_media_dst.parent, exist_ok=True) + return True - if not skip_download: - # Create a temp directory and file. - with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmp_path_dir: - tmp_path_file: pathlib.Path = pathlib.Path(tmp_path_dir) / str(uuid4()) + def _handle_metadata_and_extras( + self, + media: Track | Video, + tmp_path_file: pathlib.Path, + path_media_dst: pathlib.Path, + is_parent_album: bool, + media_stream: Stream | None, + ) -> None: + """Handle metadata, lyrics, and cover processing. - # Create empty file - tmp_path_file.touch() + Args: + media (Track | Video): Media item. + tmp_path_file (pathlib.Path): Temporary file path. + path_media_dst (pathlib.Path): Destination file path. + is_parent_album (bool): Whether this is a parent album. + media_stream (Stream | None): Media stream. + """ + if isinstance(media, Video): + return - # Download media. - result_download, tmp_path_file = self._download( - media=media, stream_manifest=stream_manifest, path_file=tmp_path_file - ) + tmp_path_lyrics: pathlib.Path | None = None + tmp_path_cover: pathlib.Path | None = None - if result_download: - # Convert video from TS to MP4 - if isinstance(media, Video) and self.settings.data.video_convert_mp4: - # Convert `*.ts` file to `*.mp4` using ffmpeg - tmp_path_file = self._video_convert(tmp_path_file) + # Write metadata to file. + if media_stream: + result_metadata, tmp_path_lyrics, tmp_path_cover = self.metadata_write( + media, tmp_path_file, is_parent_album, media_stream + ) - # Extract FLAC from MP4 container using ffmpeg - if isinstance(media, Track) and self.settings.data.extract_flac and do_flac_extract: - tmp_path_file = self._extract_flac(tmp_path_file) + # Move lyrics file + if self.settings.data.lyrics_file and tmp_path_lyrics: + self._move_lyrics(tmp_path_lyrics, path_media_dst) - tmp_path_lyrics: pathlib.Path | None = None - tmp_path_cover: pathlib.Path | None = None + # Move cover file + if self.settings.data.cover_album_file and tmp_path_cover: + self._move_cover(tmp_path_cover, path_media_dst) - # Write metadata to file. - if not isinstance(media, Video): - result_metadata, tmp_path_lyrics, tmp_path_cover = self.metadata_write( - media, tmp_path_file, is_parent_album, media_stream - ) + def _perform_post_processing( + self, + media: Track | Video, + path_media_dst: pathlib.Path, + quality_audio: Quality | None, + quality_video: QualityVideo | None, + quality_audio_old: Quality | None, + quality_video_old: QualityVideo | None, + download_delay: bool, + skip_file: bool, + ) -> None: + """Perform post-processing tasks. - # Move lyrics file - if self.settings.data.lyrics_file and not isinstance(media, Video) and tmp_path_lyrics: - self._move_lyrics(tmp_path_lyrics, path_media_dst) + Args: + media (Track | Video): Media item. + path_media_dst (pathlib.Path): Destination file path. + quality_audio (Quality | None): Audio quality setting. + quality_video (QualityVideo | None): Video quality setting. + quality_audio_old (Quality | None): Previous audio quality. + quality_video_old (QualityVideo | None): Previous video quality. + download_delay (bool): Whether to apply download delay. + skip_file (bool): Whether file was skipped. + """ + # If files needs to be symlinked, do postprocessing here. + if self.settings.data.symlink_to_track and not isinstance(media, Video): + # Determine file extension for symlink + file_extension = path_media_dst.suffix + self.media_move_and_symlink(media, path_media_dst, file_extension) - # Move cover file - # TODO: Cover is downloaded with every track of the album. Needs refactoring, so cover is only - # downloaded for an album once. - if self.settings.data.cover_album_file and tmp_path_cover: - self._move_cover(tmp_path_cover, path_media_dst) + # Reset quality settings + if quality_audio_old is not None: + self.adjust_quality_audio(quality_audio_old) - self.fn_logger.info(f"Downloaded item '{name_builder_item(media)}'.") + if quality_video_old is not None: + self.adjust_quality_video(quality_video_old) - # Move final file to the configured destination directory. - shutil.move(tmp_path_file, path_media_dst) - - # If files needs to be symlinked, do postprocessing here. - if self.settings.data.symlink_to_track and not isinstance(media, Video): - path_media_track_dir: pathlib.Path = self.media_move_and_symlink(media, path_media_dst, file_extension) - - if quality_audio: - # Set quality back to the global user value - self.adjust_quality_audio(quality_audio_old) - - if quality_video: - # Set quality back to the global user value - self.adjust_quality_video(quality_video_old) - else: - self.fn_logger.debug(f"Download skipped, since file exists: '{path_media_dst}'") - - status_download: bool = not skip_file - - # Whether a file was downloaded or skipped and the download delay is enabled, wait until the next download. - # Only use this, if you have a list of several Track items. + # Apply download delay if needed if (download_delay and not skip_file) and not self.event_abort.is_set(): time_sleep: float = round( random.SystemRandom().uniform( @@ -716,8 +929,6 @@ class Download: self.fn_logger.debug(f"Next download will start in {time_sleep} seconds.") time.sleep(time_sleep) - return status_download, path_media_dst - def media_move_and_symlink( self, media: Track | Video, path_media_src: pathlib.Path, file_extension: str ) -> pathlib.Path: From 3085cd67a1a360a904044fb1339d8ac5dedb7495 Mon Sep 17 00:00:00 2001 From: Robert Honz Date: Wed, 2 Jul 2025 15:11:19 +0200 Subject: [PATCH 23/27] =?UTF-8?q?refactor(download):=20=E2=9C=A8=20Enhance?= =?UTF-8?q?=20media=20collection=20handling=20and=20download=20execution?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Improved validation and preparation of media collections for downloads. * Refactored download context setup and execution logic for better clarity and maintainability. * Added progress tracking for download tasks and improved handling of download futures. --- tidal_dl_ng/download.py | 230 ++++++++++++++++++++++++++++++++++------ 1 file changed, 195 insertions(+), 35 deletions(-) diff --git a/tidal_dl_ng/download.py b/tidal_dl_ng/download.py index c58d699..b6b9c2e 100644 --- a/tidal_dl_ng/download.py +++ b/tidal_dl_ng/download.py @@ -1240,23 +1240,97 @@ class Download: quality_audio (Quality | None, optional): Audio quality. Defaults to None. quality_video (QualityVideo | None, optional): Video quality. Defaults to None. """ + # Validate and prepare media collection + media = self._validate_and_prepare_media_collection(media, media_id, media_type) + if media is None: + return + + # Set up download context + download_context = self._setup_collection_download_context(media, file_template, video_download) + file_name_relative, list_media_name, list_media_name_short, items, progress_stdout = download_context + + # Set up progress tracking + progress: Progress = self.progress_overall if self.progress_overall else self.progress + progress_task: TaskID = progress.add_task( + f"[green]List '{list_media_name_short}'", total=len(items), visible=progress_stdout + ) + + # Download configuration + is_album: bool = isinstance(media, Album) + sort_by_track_num: bool = bool("album_track_num" in file_name_relative or "list_pos" in file_name_relative) + list_total: int = len(items) + + # Execute downloads + result_dirs: list[pathlib.Path] = self._execute_collection_downloads( + items, + file_name_relative, + quality_audio, + quality_video, + download_delay, + is_album, + list_total, + progress, + progress_task, + progress_stdout, + ) + + # Create playlist file if requested + if self.settings.data.playlist_create: + self.playlist_populate(set(result_dirs), list_media_name, is_album, sort_by_track_num) + + self.fn_logger.info(f"Finished list '{list_media_name}'.") + + def _validate_and_prepare_media_collection( + self, + media: Album | Playlist | UserPlaylist | Mix | None, + media_id: str | None, + media_type: MediaType | None, + ) -> Album | Playlist | UserPlaylist | Mix | None: + """Validate and prepare media collection for download. + + Args: + media (Album | Playlist | UserPlaylist | Mix | None): Media collection instance. + media_id (str | None): Media ID if creating new instance. + media_type (MediaType | None): Media type if creating new instance. + + Returns: + Album | Playlist | UserPlaylist | Mix | None: Prepared media collection or None if invalid. + """ try: if media_id and media_type: # If no media instance is provided, we need to create the media instance. # Throws `tidalapi.exceptions.ObjectNotFound` if item is not available anymore. media = instantiate_media(self.session, media_type, media_id) - elif isinstance(media, Album): # Check if media is available not deactivated / removed from TIDAL. + elif isinstance(media, Album): + # Check if media is available not deactivated / removed from TIDAL. if not media.available: self.fn_logger.info( f"This item is not available for listening anymore on TIDAL. Skipping: {name_builder_title(media)}" ) - - return + return None elif not media: raise MediaMissing except: - return + return None + return media + + def _setup_collection_download_context( + self, + media: Album | Playlist | UserPlaylist | Mix, + file_template: str, + video_download: bool, + ) -> tuple[str, str, str, list, bool]: + """Set up download context for media collection. + + Args: + media (Album | Playlist | UserPlaylist | Mix): Media collection. + file_template (str): Template for file naming. + video_download (bool): Whether to allow video downloads. + + Returns: + tuple[str, str, str, list, bool]: (file_name_relative, list_media_name, list_media_name_short, items, progress_stdout) + """ # Create file name and path file_name_relative: str = format_path_media(file_template, media) @@ -1272,26 +1346,48 @@ class Download: progress_stdout: bool = True else: progress_stdout: bool = False + self.progress_gui.list_name.emit(list_media_name_short) - progress: Progress = self.progress_overall if self.progress_overall else self.progress + return file_name_relative, list_media_name, list_media_name_short, items, progress_stdout - # Create the list progress task. - p_task1: TaskID = progress.add_task( - f"[green]List '{list_media_name_short}'", total=len(items), visible=progress_stdout - ) + def _execute_collection_downloads( + self, + items: list, + file_name_relative: str, + quality_audio: Quality | None, + quality_video: QualityVideo | None, + download_delay: bool, + is_album: bool, + list_total: int, + progress: Progress, + progress_task: TaskID, + progress_stdout: bool, + ) -> list[pathlib.Path]: + """Execute downloads for all items in the collection. - is_album: bool = isinstance(media, Album) - # TODO: Refactor strings to constants (also in cfg.py) - sort_by_track_num: bool = bool("album_track_num" in file_name_relative or "list_pos" in file_name_relative) + Args: + items (list): List of media items to download. + file_name_relative (str): Relative file name template. + quality_audio (Quality | None): Audio quality setting. + quality_video (QualityVideo | None): Video quality setting. + download_delay (bool): Whether to apply download delay. + is_album (bool): Whether this is an album. + list_total (int): Total number of items. + progress (Progress): Progress bar instance. + progress_task (TaskID): Progress task ID. + progress_stdout (bool): Whether to show progress in stdout. + + Returns: + list[pathlib.Path]: List of result directories. + """ result_dirs: list[pathlib.Path] = [] - list_total: int = len(items) # Iterate through list items while not progress.finished: with futures.ThreadPoolExecutor(max_workers=self.settings.data.downloads_concurrent_max) as executor: # Dispatch all download tasks to worker threads - l_futures: list[futures.Future] = [ + download_futures: list[futures.Future] = [ executor.submit( self.item, media=item_media, @@ -1306,34 +1402,98 @@ class Download: for count, item_media in enumerate(items) ] - # Report results as they become available - for future in futures.as_completed(l_futures): - # Retrieve result - status, result_path_file = future.result() + # Process download results + result_dirs = self._process_download_futures(download_futures, progress, progress_task, progress_stdout) - if result_path_file: - result_dirs.append(result_path_file.parent) + # Check for abort signal + if self.event_abort.is_set(): + return result_dirs - # Advance progress bar. - progress.advance(p_task1) + return result_dirs - if not progress_stdout: - self.progress_gui.list_item.emit(progress.tasks[p_task1].percentage) + def _create_download_futures( + self, + items: list, + file_name_relative: str, + quality_audio: Quality | None, + quality_video: QualityVideo | None, + download_delay: bool, + is_album: bool, + list_total: int, + ) -> list[futures.Future]: + """Create download futures for all items in the collection. - # If app is terminated (CTRL+C) - if self.event_abort.is_set(): - # Cancel all not yet started tasks - for f in l_futures: - f.cancel() + Args: + items (list): List of media items to download. + file_name_relative (str): Relative file name template. + quality_audio (Quality | None): Audio quality setting. + quality_video (QualityVideo | None): Video quality setting. + download_delay (bool): Whether to apply download delay. + is_album (bool): Whether this is an album. + list_total (int): Total number of items. - # End method here. - return + Returns: + list[futures.Future]: List of download futures. + """ + with futures.ThreadPoolExecutor(max_workers=self.settings.data.downloads_concurrent_max) as executor: + return [ + executor.submit( + self.item, + media=item_media, + file_template=file_name_relative, + quality_audio=quality_audio, + quality_video=quality_video, + download_delay=download_delay, + is_parent_album=is_album, + list_position=count + 1, + list_total=list_total, + ) + for count, item_media in enumerate(items) + ] - # Create playlist file - if self.settings.data.playlist_create: - self.playlist_populate(set(result_dirs), list_media_name, is_album, sort_by_track_num) + def _process_download_futures( + self, + futures_list: list[futures.Future], + progress: Progress, + progress_task: TaskID, + progress_stdout: bool, + ) -> list[pathlib.Path]: + """Process download futures and collect results. - self.fn_logger.info(f"Finished list '{list_media_name}'.") + Args: + futures_list (list[futures.Future]): List of download futures. + progress (Progress): Progress bar instance. + progress_task (TaskID): Progress task ID. + progress_stdout (bool): Whether to show progress in stdout. + + Returns: + list[pathlib.Path]: List of result directories. + """ + result_dirs: list[pathlib.Path] = [] + + # Report results as they become available + for future in futures.as_completed(futures_list): + # Retrieve result + status, result_path_file = future.result() + + if result_path_file: + result_dirs.append(result_path_file.parent) + + # Advance progress bar. + progress.advance(progress_task) + + if not progress_stdout: + self.progress_gui.list_item.emit(progress.tasks[progress_task].percentage) + + # If app is terminated (CTRL+C) + if self.event_abort.is_set(): + # Cancel all not yet started tasks + for f in futures_list: + f.cancel() + + break + + return result_dirs def playlist_populate( self, dirs_scoped: set[pathlib.Path], name_list: str, is_album: bool, sort_alphabetically: bool From bfc0c92d69b7c16ce96384227e4e757c8fcf370b Mon Sep 17 00:00:00 2001 From: Robert Honz Date: Thu, 3 Jul 2025 08:52:04 +0200 Subject: [PATCH 24/27] fix: downgrade pyside6 version from 6.8.3 to 6.8.1 for compatibility --- poetry.lock | 233 +++++++++++++++++++++++++------------------------ pyproject.toml | 3 +- 2 files changed, 120 insertions(+), 116 deletions(-) diff --git a/poetry.lock b/poetry.lock index 0c8e9b1..3e3ead5 100644 --- a/poetry.lock +++ b/poetry.lock @@ -978,14 +978,14 @@ pyyaml = ">=5.1" [[package]] name = "mkdocs-material" -version = "9.6.14" +version = "9.6.15" description = "Documentation that simply works" optional = false python-versions = ">=3.8" groups = ["docs"] files = [ - {file = "mkdocs_material-9.6.14-py3-none-any.whl", hash = "sha256:3b9cee6d3688551bf7a8e8f41afda97a3c39a12f0325436d76c86706114b721b"}, - {file = "mkdocs_material-9.6.14.tar.gz", hash = "sha256:39d795e90dce6b531387c255bd07e866e027828b7346d3eba5ac3de265053754"}, + {file = "mkdocs_material-9.6.15-py3-none-any.whl", hash = "sha256:ac969c94d4fe5eb7c924b6d2f43d7db41159ea91553d18a9afc4780c34f2717a"}, + {file = "mkdocs_material-9.6.15.tar.gz", hash = "sha256:64adf8fa8dba1a17905b6aee1894a5aafd966d4aeb44a11088519b0f5ca4f1b5"}, ] [package.dependencies] @@ -1272,101 +1272,106 @@ setuptools = "*" [[package]] name = "pillow" -version = "11.2.1" +version = "11.3.0" description = "Python Imaging Library (Fork)" optional = false python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "pillow-11.2.1-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:d57a75d53922fc20c165016a20d9c44f73305e67c351bbc60d1adaf662e74047"}, - {file = "pillow-11.2.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:127bf6ac4a5b58b3d32fc8289656f77f80567d65660bc46f72c0d77e6600cc95"}, - {file = "pillow-11.2.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b4ba4be812c7a40280629e55ae0b14a0aafa150dd6451297562e1764808bbe61"}, - {file = "pillow-11.2.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8bd62331e5032bc396a93609982a9ab6b411c05078a52f5fe3cc59234a3abd1"}, - {file = "pillow-11.2.1-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:562d11134c97a62fe3af29581f083033179f7ff435f78392565a1ad2d1c2c45c"}, - {file = "pillow-11.2.1-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:c97209e85b5be259994eb5b69ff50c5d20cca0f458ef9abd835e262d9d88b39d"}, - {file = "pillow-11.2.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0c3e6d0f59171dfa2e25d7116217543310908dfa2770aa64b8f87605f8cacc97"}, - {file = "pillow-11.2.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc1c3bc53befb6096b84165956e886b1729634a799e9d6329a0c512ab651e579"}, - {file = "pillow-11.2.1-cp310-cp310-win32.whl", hash = "sha256:312c77b7f07ab2139924d2639860e084ec2a13e72af54d4f08ac843a5fc9c79d"}, - {file = "pillow-11.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:9bc7ae48b8057a611e5fe9f853baa88093b9a76303937449397899385da06fad"}, - {file = "pillow-11.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:2728567e249cdd939f6cc3d1f049595c66e4187f3c34078cbc0a7d21c47482d2"}, - {file = "pillow-11.2.1-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:35ca289f712ccfc699508c4658a1d14652e8033e9b69839edf83cbdd0ba39e70"}, - {file = "pillow-11.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0409af9f829f87a2dfb7e259f78f317a5351f2045158be321fd135973fff7bf"}, - {file = "pillow-11.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d4e5c5edee874dce4f653dbe59db7c73a600119fbea8d31f53423586ee2aafd7"}, - {file = "pillow-11.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b93a07e76d13bff9444f1a029e0af2964e654bfc2e2c2d46bfd080df5ad5f3d8"}, - {file = "pillow-11.2.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:e6def7eed9e7fa90fde255afaf08060dc4b343bbe524a8f69bdd2a2f0018f600"}, - {file = "pillow-11.2.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:8f4f3724c068be008c08257207210c138d5f3731af6c155a81c2b09a9eb3a788"}, - {file = "pillow-11.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a0a6709b47019dff32e678bc12c63008311b82b9327613f534e496dacaefb71e"}, - {file = "pillow-11.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f6b0c664ccb879109ee3ca702a9272d877f4fcd21e5eb63c26422fd6e415365e"}, - {file = "pillow-11.2.1-cp311-cp311-win32.whl", hash = "sha256:cc5d875d56e49f112b6def6813c4e3d3036d269c008bf8aef72cd08d20ca6df6"}, - {file = "pillow-11.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:0f5c7eda47bf8e3c8a283762cab94e496ba977a420868cb819159980b6709193"}, - {file = "pillow-11.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:4d375eb838755f2528ac8cbc926c3e31cc49ca4ad0cf79cff48b20e30634a4a7"}, - {file = "pillow-11.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:78afba22027b4accef10dbd5eed84425930ba41b3ea0a86fa8d20baaf19d807f"}, - {file = "pillow-11.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78092232a4ab376a35d68c4e6d5e00dfd73454bd12b230420025fbe178ee3b0b"}, - {file = "pillow-11.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25a5f306095c6780c52e6bbb6109624b95c5b18e40aab1c3041da3e9e0cd3e2d"}, - {file = "pillow-11.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c7b29dbd4281923a2bfe562acb734cee96bbb129e96e6972d315ed9f232bef4"}, - {file = "pillow-11.2.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:3e645b020f3209a0181a418bffe7b4a93171eef6c4ef6cc20980b30bebf17b7d"}, - {file = "pillow-11.2.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:b2dbea1012ccb784a65349f57bbc93730b96e85b42e9bf7b01ef40443db720b4"}, - {file = "pillow-11.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:da3104c57bbd72948d75f6a9389e6727d2ab6333c3617f0a89d72d4940aa0443"}, - {file = "pillow-11.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:598174aef4589af795f66f9caab87ba4ff860ce08cd5bb447c6fc553ffee603c"}, - {file = "pillow-11.2.1-cp312-cp312-win32.whl", hash = "sha256:1d535df14716e7f8776b9e7fee118576d65572b4aad3ed639be9e4fa88a1cad3"}, - {file = "pillow-11.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:14e33b28bf17c7a38eede290f77db7c664e4eb01f7869e37fa98a5aa95978941"}, - {file = "pillow-11.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:21e1470ac9e5739ff880c211fc3af01e3ae505859392bf65458c224d0bf283eb"}, - {file = "pillow-11.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fdec757fea0b793056419bca3e9932eb2b0ceec90ef4813ea4c1e072c389eb28"}, - {file = "pillow-11.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b0e130705d568e2f43a17bcbe74d90958e8a16263868a12c3e0d9c8162690830"}, - {file = "pillow-11.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bdb5e09068332578214cadd9c05e3d64d99e0e87591be22a324bdbc18925be0"}, - {file = "pillow-11.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d189ba1bebfbc0c0e529159631ec72bb9e9bc041f01ec6d3233d6d82eb823bc1"}, - {file = "pillow-11.2.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:191955c55d8a712fab8934a42bfefbf99dd0b5875078240943f913bb66d46d9f"}, - {file = "pillow-11.2.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:ad275964d52e2243430472fc5d2c2334b4fc3ff9c16cb0a19254e25efa03a155"}, - {file = "pillow-11.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:750f96efe0597382660d8b53e90dd1dd44568a8edb51cb7f9d5d918b80d4de14"}, - {file = "pillow-11.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fe15238d3798788d00716637b3d4e7bb6bde18b26e5d08335a96e88564a36b6b"}, - {file = "pillow-11.2.1-cp313-cp313-win32.whl", hash = "sha256:3fe735ced9a607fee4f481423a9c36701a39719252a9bb251679635f99d0f7d2"}, - {file = "pillow-11.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:74ee3d7ecb3f3c05459ba95eed5efa28d6092d751ce9bf20e3e253a4e497e691"}, - {file = "pillow-11.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:5119225c622403afb4b44bad4c1ca6c1f98eed79db8d3bc6e4e160fc6339d66c"}, - {file = "pillow-11.2.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:8ce2e8411c7aaef53e6bb29fe98f28cd4fbd9a1d9be2eeea434331aac0536b22"}, - {file = "pillow-11.2.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:9ee66787e095127116d91dea2143db65c7bb1e232f617aa5957c0d9d2a3f23a7"}, - {file = "pillow-11.2.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9622e3b6c1d8b551b6e6f21873bdcc55762b4b2126633014cea1803368a9aa16"}, - {file = "pillow-11.2.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63b5dff3a68f371ea06025a1a6966c9a1e1ee452fc8020c2cd0ea41b83e9037b"}, - {file = "pillow-11.2.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:31df6e2d3d8fc99f993fd253e97fae451a8db2e7207acf97859732273e108406"}, - {file = "pillow-11.2.1-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:062b7a42d672c45a70fa1f8b43d1d38ff76b63421cbbe7f88146b39e8a558d91"}, - {file = "pillow-11.2.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4eb92eca2711ef8be42fd3f67533765d9fd043b8c80db204f16c8ea62ee1a751"}, - {file = "pillow-11.2.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f91ebf30830a48c825590aede79376cb40f110b387c17ee9bd59932c961044f9"}, - {file = "pillow-11.2.1-cp313-cp313t-win32.whl", hash = "sha256:e0b55f27f584ed623221cfe995c912c61606be8513bfa0e07d2c674b4516d9dd"}, - {file = "pillow-11.2.1-cp313-cp313t-win_amd64.whl", hash = "sha256:36d6b82164c39ce5482f649b437382c0fb2395eabc1e2b1702a6deb8ad647d6e"}, - {file = "pillow-11.2.1-cp313-cp313t-win_arm64.whl", hash = "sha256:225c832a13326e34f212d2072982bb1adb210e0cc0b153e688743018c94a2681"}, - {file = "pillow-11.2.1-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:7491cf8a79b8eb867d419648fff2f83cb0b3891c8b36da92cc7f1931d46108c8"}, - {file = "pillow-11.2.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8b02d8f9cb83c52578a0b4beadba92e37d83a4ef11570a8688bbf43f4ca50909"}, - {file = "pillow-11.2.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:014ca0050c85003620526b0ac1ac53f56fc93af128f7546623cc8e31875ab928"}, - {file = "pillow-11.2.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3692b68c87096ac6308296d96354eddd25f98740c9d2ab54e1549d6c8aea9d79"}, - {file = "pillow-11.2.1-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:f781dcb0bc9929adc77bad571b8621ecb1e4cdef86e940fe2e5b5ee24fd33b35"}, - {file = "pillow-11.2.1-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:2b490402c96f907a166615e9a5afacf2519e28295f157ec3a2bb9bd57de638cb"}, - {file = "pillow-11.2.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:dd6b20b93b3ccc9c1b597999209e4bc5cf2853f9ee66e3fc9a400a78733ffc9a"}, - {file = "pillow-11.2.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:4b835d89c08a6c2ee7781b8dd0a30209a8012b5f09c0a665b65b0eb3560b6f36"}, - {file = "pillow-11.2.1-cp39-cp39-win32.whl", hash = "sha256:b10428b3416d4f9c61f94b494681280be7686bda15898a3a9e08eb66a6d92d67"}, - {file = "pillow-11.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:6ebce70c3f486acf7591a3d73431fa504a4e18a9b97ff27f5f47b7368e4b9dd1"}, - {file = "pillow-11.2.1-cp39-cp39-win_arm64.whl", hash = "sha256:c27476257b2fdcd7872d54cfd119b3a9ce4610fb85c8e32b70b42e3680a29a1e"}, - {file = "pillow-11.2.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:9b7b0d4fd2635f54ad82785d56bc0d94f147096493a79985d0ab57aedd563156"}, - {file = "pillow-11.2.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:aa442755e31c64037aa7c1cb186e0b369f8416c567381852c63444dd666fb772"}, - {file = "pillow-11.2.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f0d3348c95b766f54b76116d53d4cb171b52992a1027e7ca50c81b43b9d9e363"}, - {file = "pillow-11.2.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85d27ea4c889342f7e35f6d56e7e1cb345632ad592e8c51b693d7b7556043ce0"}, - {file = "pillow-11.2.1-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bf2c33d6791c598142f00c9c4c7d47f6476731c31081331664eb26d6ab583e01"}, - {file = "pillow-11.2.1-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:e616e7154c37669fc1dfc14584f11e284e05d1c650e1c0f972f281c4ccc53193"}, - {file = "pillow-11.2.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:39ad2e0f424394e3aebc40168845fee52df1394a4673a6ee512d840d14ab3013"}, - {file = "pillow-11.2.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:80f1df8dbe9572b4b7abdfa17eb5d78dd620b1d55d9e25f834efdbee872d3aed"}, - {file = "pillow-11.2.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ea926cfbc3957090becbcbbb65ad177161a2ff2ad578b5a6ec9bb1e1cd78753c"}, - {file = "pillow-11.2.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:738db0e0941ca0376804d4de6a782c005245264edaa253ffce24e5a15cbdc7bd"}, - {file = "pillow-11.2.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9db98ab6565c69082ec9b0d4e40dd9f6181dab0dd236d26f7a50b8b9bfbd5076"}, - {file = "pillow-11.2.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:036e53f4170e270ddb8797d4c590e6dd14d28e15c7da375c18978045f7e6c37b"}, - {file = "pillow-11.2.1-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:14f73f7c291279bd65fda51ee87affd7c1e097709f7fdd0188957a16c264601f"}, - {file = "pillow-11.2.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:208653868d5c9ecc2b327f9b9ef34e0e42a4cdd172c2988fd81d62d2bc9bc044"}, - {file = "pillow-11.2.1.tar.gz", hash = "sha256:a64dd61998416367b7ef979b73d3a85853ba9bec4c2925f74e588879a58716b6"}, + {file = "pillow-11.3.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:1b9c17fd4ace828b3003dfd1e30bff24863e0eb59b535e8f80194d9cc7ecf860"}, + {file = "pillow-11.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:65dc69160114cdd0ca0f35cb434633c75e8e7fad4cf855177a05bf38678f73ad"}, + {file = "pillow-11.3.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f1f182ebd2303acf8c380a54f615ec883322593320a9b00438eb842c1f37ae50"}, + {file = "pillow-11.3.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4445fa62e15936a028672fd48c4c11a66d641d2c05726c7ec1f8ba6a572036ae"}, + {file = "pillow-11.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:71f511f6b3b91dd543282477be45a033e4845a40278fa8dcdbfdb07109bf18f9"}, + {file = "pillow-11.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:040a5b691b0713e1f6cbe222e0f4f74cd233421e105850ae3b3c0ceda520f42e"}, + {file = "pillow-11.3.0-cp310-cp310-win32.whl", hash = "sha256:89bd777bc6624fe4115e9fac3352c79ed60f3bb18651420635f26e643e3dd1f6"}, + {file = "pillow-11.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:19d2ff547c75b8e3ff46f4d9ef969a06c30ab2d4263a9e287733aa8b2429ce8f"}, + {file = "pillow-11.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:819931d25e57b513242859ce1876c58c59dc31587847bf74cfe06b2e0cb22d2f"}, + {file = "pillow-11.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:1cd110edf822773368b396281a2293aeb91c90a2db00d78ea43e7e861631b722"}, + {file = "pillow-11.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9c412fddd1b77a75aa904615ebaa6001f169b26fd467b4be93aded278266b288"}, + {file = "pillow-11.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:643f189248837533073c405ec2f0bb250ba54598cf80e8c1e043381a60632f58"}, + {file = "pillow-11.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:106064daa23a745510dabce1d84f29137a37224831d88eb4ce94bb187b1d7e5f"}, + {file = "pillow-11.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd8ff254faf15591e724dc7c4ddb6bf4793efcbe13802a4ae3e863cd300b493e"}, + {file = "pillow-11.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:932c754c2d51ad2b2271fd01c3d121daaa35e27efae2a616f77bf164bc0b3e94"}, + {file = "pillow-11.3.0-cp311-cp311-win32.whl", hash = "sha256:b4b8f3efc8d530a1544e5962bd6b403d5f7fe8b9e08227c6b255f98ad82b4ba0"}, + {file = "pillow-11.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:1a992e86b0dd7aeb1f053cd506508c0999d710a8f07b4c791c63843fc6a807ac"}, + {file = "pillow-11.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:30807c931ff7c095620fe04448e2c2fc673fcbb1ffe2a7da3fb39613489b1ddd"}, + {file = "pillow-11.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fdae223722da47b024b867c1ea0be64e0df702c5e0a60e27daad39bf960dd1e4"}, + {file = "pillow-11.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:921bd305b10e82b4d1f5e802b6850677f965d8394203d182f078873851dada69"}, + {file = "pillow-11.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f07ed9f56a3b9b5f49d3661dc9607484e85c67e27f3e8be2c7d28ca032fec7"}, + {file = "pillow-11.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:676b2815362456b5b3216b4fd5bd89d362100dc6f4945154ff172e206a22c024"}, + {file = "pillow-11.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3e184b2f26ff146363dd07bde8b711833d7b0202e27d13540bfe2e35a323a809"}, + {file = "pillow-11.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6be31e3fc9a621e071bc17bb7de63b85cbe0bfae91bb0363c893cbe67247780d"}, + {file = "pillow-11.3.0-cp312-cp312-win32.whl", hash = "sha256:7b161756381f0918e05e7cb8a371fff367e807770f8fe92ecb20d905d0e1c149"}, + {file = "pillow-11.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a6444696fce635783440b7f7a9fc24b3ad10a9ea3f0ab66c5905be1c19ccf17d"}, + {file = "pillow-11.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:2aceea54f957dd4448264f9bf40875da0415c83eb85f55069d89c0ed436e3542"}, + {file = "pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1c627742b539bba4309df89171356fcb3cc5a9178355b2727d1b74a6cf155fbd"}, + {file = "pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:30b7c02f3899d10f13d7a48163c8969e4e653f8b43416d23d13d1bbfdc93b9f8"}, + {file = "pillow-11.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7859a4cc7c9295f5838015d8cc0a9c215b77e43d07a25e460f35cf516df8626f"}, + {file = "pillow-11.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec1ee50470b0d050984394423d96325b744d55c701a439d2bd66089bff963d3c"}, + {file = "pillow-11.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7db51d222548ccfd274e4572fdbf3e810a5e66b00608862f947b163e613b67dd"}, + {file = "pillow-11.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c37d8ba9411d6003bba9e518db0db0c58a680ab9fe5179f040b0463644bc9805"}, + {file = "pillow-11.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13f87d581e71d9189ab21fe0efb5a23e9f28552d5be6979e84001d3b8505abe8"}, + {file = "pillow-11.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:023f6d2d11784a465f09fd09a34b150ea4672e85fb3d05931d89f373ab14abb2"}, + {file = "pillow-11.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:45dfc51ac5975b938e9809451c51734124e73b04d0f0ac621649821a63852e7b"}, + {file = "pillow-11.3.0-cp313-cp313-win32.whl", hash = "sha256:a4d336baed65d50d37b88ca5b60c0fa9d81e3a87d4a7930d3880d1624d5b31f3"}, + {file = "pillow-11.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bce5c4fd0921f99d2e858dc4d4d64193407e1b99478bc5cacecba2311abde51"}, + {file = "pillow-11.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:1904e1264881f682f02b7f8167935cce37bc97db457f8e7849dc3a6a52b99580"}, + {file = "pillow-11.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4c834a3921375c48ee6b9624061076bc0a32a60b5532b322cc0ea64e639dd50e"}, + {file = "pillow-11.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5e05688ccef30ea69b9317a9ead994b93975104a677a36a8ed8106be9260aa6d"}, + {file = "pillow-11.3.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f85acb69adf2aaee8b7da124efebbdb959a104db34d3a2cb0f3793dbae422a8"}, + {file = "pillow-11.3.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05f6ecbeff5005399bb48d198f098a9b4b6bdf27b8487c7f38ca16eeb070cd59"}, + {file = "pillow-11.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a7bc6e6fd0395bc052f16b1a8670859964dbd7003bd0af2ff08342eb6e442cfe"}, + {file = "pillow-11.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:83e1b0161c9d148125083a35c1c5a89db5b7054834fd4387499e06552035236c"}, + {file = "pillow-11.3.0-cp313-cp313t-win32.whl", hash = "sha256:2a3117c06b8fb646639dce83694f2f9eac405472713fcb1ae887469c0d4f6788"}, + {file = "pillow-11.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:857844335c95bea93fb39e0fa2726b4d9d758850b34075a7e3ff4f4fa3aa3b31"}, + {file = "pillow-11.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:8797edc41f3e8536ae4b10897ee2f637235c94f27404cac7297f7b607dd0716e"}, + {file = "pillow-11.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d9da3df5f9ea2a89b81bb6087177fb1f4d1c7146d583a3fe5c672c0d94e55e12"}, + {file = "pillow-11.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0b275ff9b04df7b640c59ec5a3cb113eefd3795a8df80bac69646ef699c6981a"}, + {file = "pillow-11.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41742638139424703b4d01665b807c6468e23e699e8e90cffefe291c5832b027"}, + {file = "pillow-11.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93efb0b4de7e340d99057415c749175e24c8864302369e05914682ba642e5d77"}, + {file = "pillow-11.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7966e38dcd0fa11ca390aed7c6f20454443581d758242023cf36fcb319b1a874"}, + {file = "pillow-11.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:98a9afa7b9007c67ed84c57c9e0ad86a6000da96eaa638e4f8abe5b65ff83f0a"}, + {file = "pillow-11.3.0-cp314-cp314-win32.whl", hash = "sha256:02a723e6bf909e7cea0dac1b0e0310be9d7650cd66222a5f1c571455c0a45214"}, + {file = "pillow-11.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:a418486160228f64dd9e9efcd132679b7a02a5f22c982c78b6fc7dab3fefb635"}, + {file = "pillow-11.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:155658efb5e044669c08896c0c44231c5e9abcaadbc5cd3648df2f7c0b96b9a6"}, + {file = "pillow-11.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:59a03cdf019efbfeeed910bf79c7c93255c3d54bc45898ac2a4140071b02b4ae"}, + {file = "pillow-11.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f8a5827f84d973d8636e9dc5764af4f0cf2318d26744b3d902931701b0d46653"}, + {file = "pillow-11.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c96f993ab8c98460cd0c001447bff6194403e8b1d7e149ade5f00594918128b"}, + {file = "pillow-11.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41342b64afeba938edb034d122b2dda5db2139b9a4af999729ba8818e0056477"}, + {file = "pillow-11.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:068d9c39a2d1b358eb9f245ce7ab1b5c3246c7c8c7d9ba58cfa5b43146c06e50"}, + {file = "pillow-11.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a1bc6ba083b145187f648b667e05a2534ecc4b9f2784c2cbe3089e44868f2b9b"}, + {file = "pillow-11.3.0-cp314-cp314t-win32.whl", hash = "sha256:118ca10c0d60b06d006be10a501fd6bbdfef559251ed31b794668ed569c87e12"}, + {file = "pillow-11.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8924748b688aa210d79883357d102cd64690e56b923a186f35a82cbc10f997db"}, + {file = "pillow-11.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:79ea0d14d3ebad43ec77ad5272e6ff9bba5b679ef73375ea760261207fa8e0aa"}, + {file = "pillow-11.3.0-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:48d254f8a4c776de343051023eb61ffe818299eeac478da55227d96e241de53f"}, + {file = "pillow-11.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:7aee118e30a4cf54fdd873bd3a29de51e29105ab11f9aad8c32123f58c8f8081"}, + {file = "pillow-11.3.0-cp39-cp39-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:092c80c76635f5ecb10f3f83d76716165c96f5229addbd1ec2bdbbda7d496e06"}, + {file = "pillow-11.3.0-cp39-cp39-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cadc9e0ea0a2431124cde7e1697106471fc4c1da01530e679b2391c37d3fbb3a"}, + {file = "pillow-11.3.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:6a418691000f2a418c9135a7cf0d797c1bb7d9a485e61fe8e7722845b95ef978"}, + {file = "pillow-11.3.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:97afb3a00b65cc0804d1c7abddbf090a81eaac02768af58cbdcaaa0a931e0b6d"}, + {file = "pillow-11.3.0-cp39-cp39-win32.whl", hash = "sha256:ea944117a7974ae78059fcc1800e5d3295172bb97035c0c1d9345fca1419da71"}, + {file = "pillow-11.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:e5c5858ad8ec655450a7c7df532e9842cf8df7cc349df7225c60d5d348c8aada"}, + {file = "pillow-11.3.0-cp39-cp39-win_arm64.whl", hash = "sha256:6abdbfd3aea42be05702a8dd98832329c167ee84400a1d1f61ab11437f1717eb"}, + {file = "pillow-11.3.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:3cee80663f29e3843b68199b9d6f4f54bd1d4a6b59bdd91bceefc51238bcb967"}, + {file = "pillow-11.3.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:b5f56c3f344f2ccaf0dd875d3e180f631dc60a51b314295a3e681fe8cf851fbe"}, + {file = "pillow-11.3.0-pp310-pypy310_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:527b37216b6ac3a12d7838dc3bd75208ec57c1c6d11ef01902266a5a0c14fc27"}, + {file = "pillow-11.3.0-pp310-pypy310_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:be5463ac478b623b9dd3937afd7fb7ab3d79dd290a28e2b6df292dc75063eb8a"}, + {file = "pillow-11.3.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:8dc70ca24c110503e16918a658b869019126ecfe03109b754c402daff12b3d9f"}, + {file = "pillow-11.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7c8ec7a017ad1bd562f93dbd8505763e688d388cde6e4a010ae1486916e713e6"}, + {file = "pillow-11.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9ab6ae226de48019caa8074894544af5b53a117ccb9d3b3dcb2871464c829438"}, + {file = "pillow-11.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5418b53c0d59b3824d05e029669efa023bbef0f3e92e75ec8428f3799487f361"}, + {file = "pillow-11.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:504b6f59505f08ae014f724b6207ff6222662aab5cc9542577fb084ed0676ac7"}, + {file = "pillow-11.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c84d689db21a1c397d001aa08241044aa2069e7587b398c8cc63020390b1c1b8"}, + {file = "pillow-11.3.0.tar.gz", hash = "sha256:3828ee7586cd0b2091b6209e5ad53e20d0649bbe87164a459d0676e035e8f523"}, ] [package.extras] -docs = ["furo", "olefile", "sphinx (>=8.2)", "sphinx-copybutton", "sphinx-inline-tabs", "sphinxext-opengraph"] +docs = ["furo", "olefile", "sphinx (>=8.2)", "sphinx-autobuild", "sphinx-copybutton", "sphinx-inline-tabs", "sphinxext-opengraph"] fpx = ["olefile"] mic = ["olefile"] test-arrow = ["pyarrow"] -tests = ["check-manifest", "coverage (>=7.4.2)", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout", "trove-classifiers (>=2024.10.12)"] +tests = ["check-manifest", "coverage (>=7.4.2)", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout", "pytest-xdist", "trove-classifiers (>=2024.10.12)"] typing = ["typing-extensions ; python_version < \"3.10\""] xmp = ["defusedxml"] @@ -1634,60 +1639,60 @@ dev = ["build", "flake8", "mypy", "pytest", "twine"] [[package]] name = "pyside6" -version = "6.8.3" +version = "6.8.1" description = "Python bindings for the Qt cross-platform application and UI framework" optional = true python-versions = "<3.14,>=3.9" groups = ["main"] markers = "extra == \"gui\"" files = [ - {file = "PySide6-6.8.3-cp39-abi3-macosx_12_0_universal2.whl", hash = "sha256:31f390c961b54067ae41360e5ea3b340ce0e0e5feadea2236c28226d3b37edcc"}, - {file = "PySide6-6.8.3-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:8e53e2357bfbdee1fa86c48312bf637460a2c26d49e7af0b3fae2e179ccc7052"}, - {file = "PySide6-6.8.3-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:5bf5153cab9484629315f57c56a9ad4b7d075b4dd275f828f7549abf712c590b"}, - {file = "PySide6-6.8.3-cp39-abi3-win_amd64.whl", hash = "sha256:722dc0061d8ef6dbb8c0b99864f21e83a5b49ece1ecb2d0b890840d969e1e461"}, + {file = "PySide6-6.8.1-cp39-abi3-macosx_12_0_universal2.whl", hash = "sha256:6d1fd95651cdbdea741af21e155350986eca31ff015fc4c721ce01c2a110a4cc"}, + {file = "PySide6-6.8.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:7d6adc5d53313249bbe02edb673877c1d437e215d71e88da78412520653f5c9f"}, + {file = "PySide6-6.8.1-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:ddeeaeca8ebd0ddb1ded30dd33e9240a40f330cc91832de346ba6c9d0cd1253e"}, + {file = "PySide6-6.8.1-cp39-abi3-win_amd64.whl", hash = "sha256:866eeaca3ffead6b9d30fa3ed395d5624da0246d7586c8b8207e77ac65d82458"}, ] [package.dependencies] -PySide6-Addons = "6.8.3" -PySide6-Essentials = "6.8.3" -shiboken6 = "6.8.3" +PySide6-Addons = "6.8.1" +PySide6-Essentials = "6.8.1" +shiboken6 = "6.8.1" [[package]] name = "pyside6-addons" -version = "6.8.3" +version = "6.8.1" description = "Python bindings for the Qt cross-platform application and UI framework (Addons)" optional = true python-versions = "<3.14,>=3.9" groups = ["main"] markers = "extra == \"gui\"" files = [ - {file = "PySide6_Addons-6.8.3-cp39-abi3-macosx_12_0_universal2.whl", hash = "sha256:ea46649e40b9e6ab11a0da2da054d3914bff5607a5882885e9c3bc2eef200036"}, - {file = "PySide6_Addons-6.8.3-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6983d3b01fad53637bad5360930d5923509c744cc39704f9c1190eb9934e33da"}, - {file = "PySide6_Addons-6.8.3-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:7949a844a40ee10998eb2734e2c06c4c7182dfcd4c21cc4108a6b96655ebe59f"}, - {file = "PySide6_Addons-6.8.3-cp39-abi3-win_amd64.whl", hash = "sha256:67548f6db11f4e1b7e4b6efd9c3fc2e8d275188a7b2feac388961128572a6955"}, + {file = "PySide6_Addons-6.8.1-cp39-abi3-macosx_12_0_universal2.whl", hash = "sha256:879c12346b4b76f5d5ee6499d8ca53b5666c0c998b8fdf8780f08f69ea95d6f9"}, + {file = "PySide6_Addons-6.8.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f80cc03c1ac54132c6f800aa461dced64acd7d1646898db164ccb56fe3c23dd4"}, + {file = "PySide6_Addons-6.8.1-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:570a25016d80046274f454ed0bb06734f478ce6c21be5dec62b624773fc7504e"}, + {file = "PySide6_Addons-6.8.1-cp39-abi3-win_amd64.whl", hash = "sha256:d7c8c1e89ee0db84631d5b8fdb9129d9d2a0ffb3b4cb2f5192dc8367dd980db4"}, ] [package.dependencies] -PySide6-Essentials = "6.8.3" -shiboken6 = "6.8.3" +PySide6-Essentials = "6.8.1" +shiboken6 = "6.8.1" [[package]] name = "pyside6-essentials" -version = "6.8.3" +version = "6.8.1" description = "Python bindings for the Qt cross-platform application and UI framework (Essentials)" optional = true python-versions = "<3.14,>=3.9" groups = ["main"] markers = "extra == \"gui\"" files = [ - {file = "PySide6_Essentials-6.8.3-cp39-abi3-macosx_12_0_universal2.whl", hash = "sha256:aa56c135db924ecfaf50088baf32f737d28027419ca5fee67c0c7141b29184e3"}, - {file = "PySide6_Essentials-6.8.3-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:fd57fa0c886ef99b3844173322c0023ec77cc946a0c9a0cdfbc2ac5c511053c1"}, - {file = "PySide6_Essentials-6.8.3-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:b4f4823f870b5bed477d6f7b6a3041839b859f70abfd703cf53208c73c2fe4cd"}, - {file = "PySide6_Essentials-6.8.3-cp39-abi3-win_amd64.whl", hash = "sha256:3c0fae5550aff69f2166f46476c36e0ef56ce73d84829eac4559770b0c034b07"}, + {file = "PySide6_Essentials-6.8.1-cp39-abi3-macosx_12_0_universal2.whl", hash = "sha256:bd05155245e3cd1572e68d72772e78fadfd713575bbfdd2c5e060d5278e390e9"}, + {file = "PySide6_Essentials-6.8.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2f600b149e65b57acd6a444edb17615adc42cc2491548ae443ccb574036d86b1"}, + {file = "PySide6_Essentials-6.8.1-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:bf8a3c9ee0b997eb18fb00cb09aacaa28b8a51ce3c295a252cc594c5530aba56"}, + {file = "PySide6_Essentials-6.8.1-cp39-abi3-win_amd64.whl", hash = "sha256:d5ed4ddb149f36d65bc49ae4260b2d213ee88b2d9a309012ae27f38158c2d1b6"}, ] [package.dependencies] -shiboken6 = "6.8.3" +shiboken6 = "6.8.1" [[package]] name = "pytest" @@ -1957,17 +1962,17 @@ files = [ [[package]] name = "shiboken6" -version = "6.8.3" +version = "6.8.1" description = "Python/C++ bindings helper module" optional = true python-versions = "<3.14,>=3.9" groups = ["main"] markers = "extra == \"gui\"" files = [ - {file = "shiboken6-6.8.3-cp39-abi3-macosx_12_0_universal2.whl", hash = "sha256:483efc7dd53c69147b8a8ade71f7619c79ffc683efcb1dc4f4cb6c40bb23d29b"}, - {file = "shiboken6-6.8.3-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:295a003466ca2cccf6660e2f2ceb5e6cef4af192a48a196a32d46b6f0c9ec5cb"}, - {file = "shiboken6-6.8.3-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:2b1a41348102952d2a5fbf3630bddd4d44112e18058b5e4cf505e51f2812429d"}, - {file = "shiboken6-6.8.3-cp39-abi3-win_amd64.whl", hash = "sha256:bca3a94513ce9242f7d4bbdca902072a1631888e0aa3a8711a52cc5dbe93588f"}, + {file = "shiboken6-6.8.1-cp39-abi3-macosx_12_0_universal2.whl", hash = "sha256:9a2f51d1ddd3b6d193a0f0fdc09f8d41f2092bc664723c9b9efc1056660d0608"}, + {file = "shiboken6-6.8.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1dc4c1976809b0e68872bb98474cccd590455bdcd015f0e0639907e94af27b6a"}, + {file = "shiboken6-6.8.1-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:ab5b60602ca6227103138aae89c4f5df3b1b8e249cbc8ec9e6e2a57f20ad9a91"}, + {file = "shiboken6-6.8.1-cp39-abi3-win_amd64.whl", hash = "sha256:3ea127fd72be113b73cacd70e06687ad6f83c1c888047833c7dcdd5cf8e7f586"}, ] [[package]] @@ -2338,4 +2343,4 @@ gui = ["pyqtdarktheme-fork", "pyside6"] [metadata] lock-version = "2.1" python-versions = ">=3.12,<3.13" -content-hash = "316194a2b8f04f23039eeeac048da8e0ca23417799ae31a46713a5157bb703c6" +content-hash = "04e7d96d0b9a2e4777c9c2ad4305b81704acfec50ab7dfceca91666e985a57d7" diff --git a/pyproject.toml b/pyproject.toml index 700c9fc..b73269c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,9 +34,8 @@ dataclasses-json = "^0.6.7" pathvalidate = "^3.3.1" m3u8 = "^6.0.0" coloredlogs = "^15.0.1" -pyside6 = { version = "6.8.3", optional = true } +pyside6 = { version = "6.8.1", optional = true } pyqtdarktheme-fork = { version = "^2.3.4", optional = true } -mpegdash = "^0.4.0" rich = "^14.0.0" toml = "^0.10.2" typer = "^0.16.0" From a96d44170b72eab3417c410398d4e0a3ba144ba7 Mon Sep 17 00:00:00 2001 From: Robert Honz Date: Thu, 3 Jul 2025 08:53:44 +0200 Subject: [PATCH 25/27] =?UTF-8?q?refactor(download):=20=F0=9F=94=A7=20Impr?= =?UTF-8?q?ove=20media=20validation=20and=20preparation=20logic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Enhanced `_validate_and_prepare_media` method to support additional media types: `Album`, `Playlist`, `UserPlaylist`, and `Mix`. * Updated media validation checks to ensure proper handling of unavailable media. * Refactored `_validate_media_instance` to streamline media instance creation and validation. * Removed redundant `_validate_and_prepare_media_collection` method for cleaner code. --- tidal_dl_ng/download.py | 81 +++++++++++++---------------------------- 1 file changed, 25 insertions(+), 56 deletions(-) diff --git a/tidal_dl_ng/download.py b/tidal_dl_ng/download.py index b6b9c2e..ba604b6 100644 --- a/tidal_dl_ng/download.py +++ b/tidal_dl_ng/download.py @@ -528,10 +528,12 @@ class Download: tuple[bool, pathlib.Path | str]: (Downloaded, path to file) """ # Step 1: Validate and prepare media - media = self._validate_media_instance(media, media_id, media_type, video_download) - if media is None: + validated_media = self._validate_and_prepare_media(media, media_id, media_type, video_download) + if validated_media is None or not isinstance(validated_media, Track | Video): return False, "" + media = validated_media + # Step 2: Create file paths and determine skip logic path_media_dst, file_extension_dummy, skip_file, skip_download = self._prepare_file_paths_and_skip_logic( media, file_template, quality_audio, list_position, list_total @@ -564,56 +566,56 @@ class Download: return download_success, path_media_dst - def _validate_media_instance( + def _validate_and_prepare_media( self, - media: Track | Video | None, + media: Track | Video | Album | Playlist | UserPlaylist | Mix | None, media_id: str | None, media_type: MediaType | None, - video_download: bool, - ) -> Track | Video | None: + video_download: bool = True, + ) -> Track | Video | Album | Playlist | UserPlaylist | Mix | None: """Validate and prepare media instance for download. Args: - media (Track | Video | None): Media item instance. + media (Track | Video | Album | Playlist | UserPlaylist | Mix | None): Media instance. media_id (str | None): Media ID if creating new instance. media_type (MediaType | None): Media type if creating new instance. - video_download (bool): Whether video downloads are allowed. + video_download (bool, optional): Whether video downloads are allowed. Defaults to True. Returns: - Track | Video | None: Prepared media instance or None if invalid. + Track | Video | Album | Playlist | UserPlaylist | Mix | None: Prepared media instance or None if invalid. """ try: if media_id and media_type: # If no media instance is provided, we need to create the media instance. - media_instance = instantiate_media(self.session, media_type, media_id) - - # Filter to only Track or Video types - if isinstance(media_instance, Track | Video): - media = media_instance - else: - return None + # Throws `tidalapi.exceptions.ObjectNotFound` if item is not available anymore. + media = instantiate_media(self.session, media_type, media_id) elif isinstance(media, Track | Video): # Check if media is available not deactivated / removed from TIDAL. if not media.available: self.fn_logger.info( f"This item is not available for listening anymore on TIDAL. Skipping: {name_builder_item(media)}" ) - return None elif isinstance(media, Track): # Re-create media instance with full album information media = self.session.track(str(media.id), with_album=True) + elif isinstance(media, Album): + # Check if media is available not deactivated / removed from TIDAL. + if not media.available: + self.fn_logger.info( + f"This item is not available for listening anymore on TIDAL. Skipping: {name_builder_title(media)}" + ) + return None elif not media: raise MediaMissing except: return None - # If video download is not allowed end here + # If video download is not allowed and this is a video, return None if not video_download and isinstance(media, Video): self.fn_logger.info( f"Video downloads are deactivated (see settings). Skipping video: {name_builder_item(media)}" ) - return None return media @@ -1241,10 +1243,12 @@ class Download: quality_video (QualityVideo | None, optional): Video quality. Defaults to None. """ # Validate and prepare media collection - media = self._validate_and_prepare_media_collection(media, media_id, media_type) - if media is None: + validated_media = self._validate_and_prepare_media(media, media_id, media_type, video_download) + if validated_media is None or not isinstance(validated_media, Album | Playlist | UserPlaylist | Mix): return + media = validated_media + # Set up download context download_context = self._setup_collection_download_context(media, file_template, video_download) file_name_relative, list_media_name, list_media_name_short, items, progress_stdout = download_context @@ -1280,41 +1284,6 @@ class Download: self.fn_logger.info(f"Finished list '{list_media_name}'.") - def _validate_and_prepare_media_collection( - self, - media: Album | Playlist | UserPlaylist | Mix | None, - media_id: str | None, - media_type: MediaType | None, - ) -> Album | Playlist | UserPlaylist | Mix | None: - """Validate and prepare media collection for download. - - Args: - media (Album | Playlist | UserPlaylist | Mix | None): Media collection instance. - media_id (str | None): Media ID if creating new instance. - media_type (MediaType | None): Media type if creating new instance. - - Returns: - Album | Playlist | UserPlaylist | Mix | None: Prepared media collection or None if invalid. - """ - try: - if media_id and media_type: - # If no media instance is provided, we need to create the media instance. - # Throws `tidalapi.exceptions.ObjectNotFound` if item is not available anymore. - media = instantiate_media(self.session, media_type, media_id) - elif isinstance(media, Album): - # Check if media is available not deactivated / removed from TIDAL. - if not media.available: - self.fn_logger.info( - f"This item is not available for listening anymore on TIDAL. Skipping: {name_builder_title(media)}" - ) - return None - elif not media: - raise MediaMissing - except: - return None - - return media - def _setup_collection_download_context( self, media: Album | Playlist | UserPlaylist | Mix, From 5b110638e0b1c2005ed04887537c1167e4a3b927 Mon Sep 17 00:00:00 2001 From: Robert Honz Date: Thu, 3 Jul 2025 08:57:46 +0200 Subject: [PATCH 26/27] =?UTF-8?q?refactor(cli):=20=F0=9F=94=A7=20Update=20?= =?UTF-8?q?context=20handling=20in=20download=20functions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Refactored `_handle_track_or_video` and `_handle_album_playlist_mix_artist` to use `typer.Context` instead of `Settings` directly. * Improved clarity by centralizing settings retrieval through context. * Adjusted `_process_url` and `_download` functions to align with the new context-based approach. --- tidal_dl_ng/cli.py | 33 +++++++++++++++++---------------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/tidal_dl_ng/cli.py b/tidal_dl_ng/cli.py index 72ecf06..85e9f6e 100644 --- a/tidal_dl_ng/cli.py +++ b/tidal_dl_ng/cli.py @@ -48,23 +48,25 @@ def version_callback(value: bool): """ if value: print(f"{__version__}") + raise typer.Exit() def _handle_track_or_video( - dl: Download, settings: Settings, item: str, media: object, file_template: str, idx: int, urls_pos_last: int + dl: Download, ctx: typer.Context, item: str, media: object, file_template: str, idx: int, urls_pos_last: int ) -> None: """Handle downloading a track or video item. Args: dl (Download): The Download instance. - settings (Settings): The Settings instance. + ctx (typer.Context): Typer context object. item (str): The URL or identifier of the item. media: The media object to download. file_template (str): The file template for saving the media. idx (int): The index of the item in the list. urls_pos_last (int): The last index in the URLs list. """ + settings = ctx.obj[CTX_TIDAL].settings download_delay: bool = bool(settings.data.download_delay and idx < urls_pos_last) dl.item( @@ -77,10 +79,9 @@ def _handle_track_or_video( def _handle_album_playlist_mix_artist( - dl: Download, ctx: typer.Context, + dl: Download, handling_app: HandlingApp, - settings: Settings, media_type: MediaType, media: object, item_id: str, @@ -89,10 +90,9 @@ def _handle_album_playlist_mix_artist( """Handle downloading albums, playlists, mixes, or artist collections. Args: - dl (Download): The Download instance. ctx (typer.Context): Typer context object. + dl (Download): The Download instance. handling_app (HandlingApp): The HandlingApp instance. - settings (Settings): The Settings instance. media_type (MediaType): The type of media (album, playlist, mix, or artist). media: The media object to download. item_id (str): The ID of the media item. @@ -102,6 +102,7 @@ def _handle_album_playlist_mix_artist( bool: False if aborted, True otherwise. """ item_ids: list[str] = [] + settings = ctx.obj[CTX_TIDAL].settings if media_type == MediaType.ARTIST: media_type = MediaType.ALBUM @@ -117,8 +118,10 @@ def _handle_album_playlist_mix_artist( media_id=_item_id, media_type=media_type, file_template=file_template, - video_download=ctx.obj[CTX_TIDAL].settings.data.video_download, + video_download=settings.data.video_download, download_delay=settings.data.download_delay, + quality_audio=settings.data.quality_audio, + quality_video=settings.data.quality_video, ) return True @@ -128,7 +131,6 @@ def _process_url( dl: Download, ctx: typer.Context, handling_app: HandlingApp, - settings: Settings, item: str, idx: int, urls_pos_last: int, @@ -139,7 +141,6 @@ def _process_url( dl (Download): The Download instance. ctx (typer.Context): Typer context object. handling_app (HandlingApp): The HandlingApp instance. - settings (Settings): The Settings instance. item (str): The URL or identifier to process. idx (int): The index of the item in the list. urls_pos_last (int): The last index in the URLs list. @@ -147,6 +148,8 @@ def _process_url( Returns: bool: False if aborted, True otherwise. """ + settings = ctx.obj[CTX_TIDAL].settings + if handling_app.event_abort.is_set(): return False @@ -176,11 +179,9 @@ def _process_url( return True if media_type in [MediaType.TRACK, MediaType.VIDEO]: - _handle_track_or_video(dl, settings, item, media, file_template, idx, urls_pos_last) + _handle_track_or_video(dl, ctx, item, media, file_template, idx, urls_pos_last) elif media_type in [MediaType.ALBUM, MediaType.PLAYLIST, MediaType.MIX, MediaType.ARTIST]: - return _handle_album_playlist_mix_artist( - dl, ctx, handling_app, settings, media_type, media, item_id, file_template - ) + return _handle_album_playlist_mix_artist(ctx, dl, handling_app, media_type, media, item_id, file_template) return True @@ -198,7 +199,7 @@ def _download(ctx: typer.Context, urls: list[str], try_login: bool = True) -> bo if try_login: ctx.invoke(login, ctx) - settings: Settings = Settings() + settings: Settings = ctx.obj[CTX_TIDAL].settings handling_app: HandlingApp = HandlingApp() progress: Progress = Progress( @@ -227,7 +228,7 @@ def _download(ctx: typer.Context, urls: list[str], try_login: bool = True) -> bo dl = Download( session=ctx.obj[CTX_TIDAL].session, - skip_existing=ctx.obj[CTX_TIDAL].settings.data.skip_existing, + skip_existing=settings.data.skip_existing, path_base=settings.data.download_base_path, fn_logger=fn_logger, progress=progress, @@ -246,7 +247,7 @@ def _download(ctx: typer.Context, urls: list[str], try_login: bool = True) -> bo with Live(progress_group, refresh_per_second=20, vertical_overflow="visible"): try: for idx, item in enumerate(urls): - if _process_url(dl, ctx, handling_app, settings, item, idx, urls_pos_last) is False: + if _process_url(dl, ctx, handling_app, item, idx, urls_pos_last) is False: return False finally: progress.refresh() From 4662d18fdfc25d42d496cf6abb1c8d44be0c76dd Mon Sep 17 00:00:00 2001 From: Robert Honz Date: Thu, 3 Jul 2025 09:45:18 +0200 Subject: [PATCH 27/27] =?UTF-8?q?refactor(decryption):=20=E2=9C=A8=20Impro?= =?UTF-8?q?ve=20docstring=20for=20`decrypt=5Fsecurity=5Ftoken`=20function?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Enhanced clarity of the function's purpose and usage. - Added detailed descriptions for arguments and return values. --- tidal_dl_ng/helper/decryption.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tidal_dl_ng/helper/decryption.py b/tidal_dl_ng/helper/decryption.py index 17ea19b..2f38fc5 100644 --- a/tidal_dl_ng/helper/decryption.py +++ b/tidal_dl_ng/helper/decryption.py @@ -7,9 +7,17 @@ from Crypto.Util import Counter def decrypt_security_token(security_token: str) -> (str, str): """ - Decrypts security token into key and nonce pair + The `decrypt_security_token` function decrypts a security token into a key and nonce pair using AES + encryption. - security_token should match the securityToken value from the web response + Args: + security_token (str): The `security_token` parameter in the `decrypt_security_token` function is a + string that represents an encrypted security token. This function decrypts the security token into a + key and nonce pair using AES encryption. security_token should match the securityToken value from the web response. + + Returns: + The `decrypt_security_token` function returns a tuple containing the key and nonce extracted from + the decrypted security token. """ # Do not change this