import math import os import pathlib import posixpath import re import sys from copy import deepcopy from urllib.parse import unquote, urlsplit from pathvalidate import sanitize_filename, sanitize_filepath from pathvalidate.error import ValidationError from tidalapi import Album, Mix, Playlist, Track, UserPlaylist, Video from tidalapi.media import AudioExtensions from tidal_dl_ng import __name_display__ from tidal_dl_ng.constants import ( FILENAME_LENGTH_MAX, FILENAME_SANITIZE_PLACEHOLDER, FORMAT_TEMPLATE_EXPLICIT, UNIQUIFY_THRESHOLD, MediaType, ) from tidal_dl_ng.helper.tidal import name_builder_album_artist, name_builder_artist, name_builder_title 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: return os.environ["HOME"] elif "HOMEDRIVE" in os.environ and "HOMEPATH" in os.environ: return os.path.join(os.environ["HOMEDRIVE"], os.environ["HOMEPATH"]) else: return os.path.abspath("./") 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", "") path_config: str = ".config" if not path_user_custom else "" path_base: str = os.path.join(path_home(), path_config, __name_display__) return path_base 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") def format_path_media( fmt_template: str, media: Track | Album | Playlist | UserPlaylist | Video | Mix, album_track_num_pad_min: int = 0, list_pos: int = 0, list_total: int = 0, delimiter_artist: str = ", ", delimiter_album_artist: str = ", ", use_primary_album_artist: bool = False, ) -> str: """Formats a media path string using a template and media attributes. Replaces placeholders in the format template with sanitized media attribute values to generate a valid file path. Args: fmt_template (str): The format template string containing placeholders. media (Track | Album | Playlist | UserPlaylist | Video | Mix): The media object to extract values from. album_track_num_pad_min (int, optional): Minimum padding for track numbers. Defaults to 0. list_pos (int, optional): Position in a list. Defaults to 0. list_total (int, optional): Total items in a list. Defaults to 0. delimiter_artist (str, optional): Delimiter for artist names. Defaults to ", ". delimiter_album_artist (str, optional): Delimiter for album artist names. Defaults to ", ". use_primary_album_artist (bool, optional): If True, uses first album artist for folder paths. Defaults to False. Returns: str: The formatted and sanitized media path string. """ result = fmt_template # Search track format template for placeholder. regex = r"\{(.+?)\}" matches = re.finditer(regex, fmt_template, re.MULTILINE) for _matchNum, match in enumerate(matches, start=1): template_str = match.group() result_fmt = format_str_media( match.group(1), media, album_track_num_pad_min, list_pos, list_total, delimiter_artist=delimiter_artist, delimiter_album_artist=delimiter_album_artist, use_primary_album_artist=use_primary_album_artist, ) if result_fmt != match.group(1): # Sanitize here, in case of the filename has slashes or something, which will be recognized later as a directory separator. # Do not sanitize if value is the FORMAT_TEMPLATE_EXPLICIT placeholder, since it has a leading whitespace which otherwise gets removed. value = ( sanitize_filename(result_fmt) if result_fmt != FORMAT_TEMPLATE_EXPLICIT else FORMAT_TEMPLATE_EXPLICIT ) result = result.replace(template_str, value) return result def format_str_media( name: str, media: Track | Album | Playlist | UserPlaylist | Video | Mix, album_track_num_pad_min: int = 0, list_pos: int = 0, list_total: int = 0, delimiter_artist: str = ", ", delimiter_album_artist: str = ", ", use_primary_album_artist: bool = False, ) -> str: """Formats a string for media attributes based on the provided name. Attempts to format the given name using a sequence of formatter functions, returning the first successful result. Args: name (str): The format string name to process. media (Track | Album | Playlist | UserPlaylist | Video | Mix): The media object to extract values from. album_track_num_pad_min (int, optional): Minimum padding for track numbers. Defaults to 0. list_pos (int, optional): Position in a list. Defaults to 0. list_total (int, optional): Total items in a list. Defaults to 0. delimiter_artist (str, optional): Delimiter for artist names. Defaults to ", ". delimiter_album_artist (str, optional): Delimiter for album artist names. Defaults to ", ". use_primary_album_artist (bool, optional): If True, uses first album artist for folder paths. Defaults to False. Returns: str: The formatted string for the media attribute, or the original name if no formatter matches. """ try: # Try each formatter function in sequence for formatter in ( _format_names, _format_numbers, _format_ids, _format_durations, _format_dates, _format_metadata, _format_volumes, ): result = formatter( name, media, album_track_num_pad_min, list_pos, list_total, delimiter_artist=delimiter_artist, delimiter_album_artist=delimiter_album_artist, use_primary_album_artist=use_primary_album_artist, ) if result is not None: return result except (AttributeError, KeyError, TypeError, ValueError) as e: print(f"Error formatting path for media attribute '{name}': {e}") return name def _format_artist_names( name: str, media: Track | Album | Playlist | UserPlaylist | Video | Mix, delimiter_artist: str = ", ", delimiter_album_artist: str = ", ", *_args, use_primary_album_artist: bool = False, **kwargs, ) -> 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. delimiter_artist (str, optional): Delimiter for artist names. Defaults to ", ". delimiter_album_artist (str, optional): Delimiter for album artist names. Defaults to ", ". use_primary_album_artist (bool, optional): If True, uses first album artist for folder paths. Defaults to False. *_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): # For folder paths, use album artist if setting is enabled if use_primary_album_artist and hasattr(media, "album") and media.album and media.album.artists: return media.album.artists[0].name # Otherwise use track artists as before if hasattr(media, "artists"): return name_builder_artist(media, delimiter=delimiter_artist) 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, delimiter=delimiter_album_artist) return None def _format_titles( name: str, media: Track | Album | Playlist | UserPlaylist | Video | Mix, *_args, **kwargs ) -> 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, delimiter_artist: str = ", ", delimiter_album_artist: str = ", ", use_primary_album_artist: bool = False, **kwargs, ) -> str | None: """Handles name-related format strings for media. Tries to format the provided name as an artist or title, returning the first matching result. 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: Additional arguments (not used). delimiter_artist (str, optional): Delimiter for artist names. Defaults to ", ". delimiter_album_artist (str, optional): Delimiter for album artist names. Defaults to ", ". use_primary_album_artist (bool, optional): If True, uses first album artist for folder paths. Defaults to False. 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, delimiter_artist=delimiter_artist, delimiter_album_artist=delimiter_album_artist, use_primary_album_artist=use_primary_album_artist, ) 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, **kwargs, ) -> 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, **kwargs ) -> 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)) or (name == "playlist_id" and isinstance(media, Playlist)) or (name == "video_id" and isinstance(media, Video)) ): 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 elif name == "album_artist_id" and isinstance(media, Album): return str(media.artist.id) elif name == "track_artist_id" and isinstance(media, Track): return str(media.album.artist.id) return None def _format_durations( name: str, media: Track | Album | Playlist | UserPlaylist | Video | Mix, *_args, **kwargs ) -> 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}" return None def _format_dates( name: str, media: Track | Album | Playlist | UserPlaylist | Video | Mix, *_args, **kwargs ) -> str | None: """Handle date-related format strings. Args: name (str): The format string name to check. media (Track | Album | Playlist | UserPlaylist | Video | Mix): The media object to extract date information from. *_args (Any): Additional arguments (not used). Returns: str | None: The formatted date or None if the format string is not date-related. """ if name == "album_year": if isinstance(media, Album): return str(media.year) elif isinstance(media, Track): return str(media.album.year) elif name == "album_date": if isinstance(media, Album): return media.release_date.strftime("%Y-%m-%d") if media.release_date else None elif isinstance(media, Track): return media.album.release_date.strftime("%Y-%m-%d") if media.album.release_date else None return None def _format_metadata( name: str, media: Track | Album | Playlist | UserPlaylist | Video | Mix, *_args, **kwargs ) -> 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 "" elif name == "media_type": if isinstance(media, Album): return media.type elif isinstance(media, Track): return media.album.type return None def _format_volumes( name: str, media: Track | Album | Playlist | UserPlaylist | Video | Mix, *_args, **kwargs ) -> 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 = max(int(math.log10(items_max)) + 1, padding_minimum) result = str(item_position).zfill(count_digits) else: result = str(item_position) return result 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: result = settings.data.format_track elif isinstance(media, Album) or media == MediaType.ALBUM or media == MediaType.ARTIST: result = settings.data.format_album elif isinstance(media, Playlist | UserPlaylist) or media == MediaType.PLAYLIST: result = settings.data.format_playlist elif isinstance(media, Mix) or media == MediaType.MIX: result = settings.data.format_mix elif isinstance(media, Video) or media == MediaType.VIDEO: result = settings.data.format_video return result def path_file_sanitize(path_file: pathlib.Path, adapt: bool = False, uniquify: bool = False) -> pathlib.Path: """Sanitize a file path to ensure it is valid and optionally make it unique. 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) - len(FILENAME_SANITIZE_PLACEHOLDER)] + FILENAME_SANITIZE_PLACEHOLDER + path_file.suffix ) 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 ] ) try: sanitized_path = sanitize_filepath( sanitized_path, replacement_text="_", validate_after_sanitize=True, platform="auto" ) except ValidationError as e: if adapt and str(e).startswith("[PV1101]"): sanitized_path = pathlib.Path.home() else: raise result = sanitized_path / sanitized_filename return path_file_uniquify(result) if uniquify else result def path_file_uniquify(path_file: pathlib.Path) -> pathlib.Path: """Ensure a file path is unique by appending a suffix if necessary. 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) if unique_suffix: file_suffix = unique_suffix + path_file.suffix # For most OS filename has a character limit of 255. path_file = ( path_file.parent / (str(path_file.stem)[: -len(file_suffix)] + file_suffix) if len(str(path_file.parent / (path_file.stem + unique_suffix))) > FILENAME_LENGTH_MAX else path_file.parent / (path_file.stem + unique_suffix) ) return path_file 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) unique_suffix: str = "" while check_file_exists(path_file_tmp) and count < UNIQUIFY_THRESHOLD: count += 1 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: list[str] = [] path_files.extend(str(path_parent.joinpath(path_file_stem + extension)) for extension in AudioExtensions) else: path_files: list[str] = [str(path_file)] return any(os.path.isfile(_file) for _file in path_files) def resource_path(relative_path: str) -> str: """Get the absolute path to a resource. 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: """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)) if os.path.basename(basename) != basename or unquote(posixpath.basename(urlpath)) != basename: raise ValueError # reject '%2f' or 'dir%5Cbasename.ext' on Windows return basename