diff options
| author | exislow <exislow@users.noreply.github.com> | 2025-07-03 09:57:26 +0200 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-07-03 09:57:26 +0200 |
| commit | 7da0d4c41e4abb6ec03ee1b0f64474ee970a230e (patch) | |
| tree | a5e625565e2439ad92c590f7f9d2aeaf49824fe9 /tidal_dl_ng/helper | |
| parent | 29ea5c51825628dbe96ce4404488b87d5fadb80d (diff) | |
| parent | 4662d18fdfc25d42d496cf6abb1c8d44be0c76dd (diff) | |
| download | tidal-dl-ng-7da0d4c41e4abb6ec03ee1b0f64474ee970a230e.tar.gz | |
Merge pull request #478 from exislow/ai-refactoring
Diffstat (limited to 'tidal_dl_ng/helper')
| -rw-r--r-- | tidal_dl_ng/helper/decryption.py | 12 | ||||
| -rw-r--r-- | tidal_dl_ng/helper/path.py | 581 |
2 files changed, 402 insertions, 191 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 diff --git a/tidal_dl_ng/helper/path.py b/tidal_dl_ng/helper/path.py index 29347cb..12ffdce 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") @@ -91,130 +116,300 @@ 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) - 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) - pass + 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 - return result + +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: + """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 +419,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 +445,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. - # 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 + Returns: + pathlib.Path: The sanitized file path. + """ + sanitized_filename = sanitize_filename( + path_file.name, replacement_text="_", validate_after_sanitize=True, platform="auto" + ) - # 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 = [] + if not sanitized_filename.endswith(path_file.suffix): + sanitized_filename = ( + sanitized_filename[: -len(path_file.suffix)] + FILENAME_SANITIZE_PLACEHOLDER + path_file.suffix + ) - 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. + + Args: + path_file (pathlib.Path): The file path to uniquify. - :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 + Returns: + pathlib.Path: The unique file path. """ unique_suffix: str = file_unique_suffix(path_file) @@ -333,7 +513,16 @@ 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. + separator (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) @@ -341,46 +530,60 @@ 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 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 - 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 = bool(sum([[True] if os.path.isfile(_file) else [] for _file in path_files], [])) + return any(os.path.isfile(_file) for _file in path_files) - return result +def resource_path(relative_path: str) -> str: + """Get the absolute path to a resource. -def resource_path(relative_path): - try: - base_path = sys._MEIPASS - except Exception: - base_path = os.path.abspath(".") + Args: + relative_path: The relative path to the resource. + + Returns: + str: The absolute path to the resource. + """ + # 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) 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)) |
