This commit is contained in:
2025-12-02 14:07:35 +01:00
commit 9b84566eb4
62 changed files with 12861 additions and 0 deletions

View File

View File

@@ -0,0 +1,22 @@
from typing import ClassVar
class SingletonMeta(type):
"""
The Singleton class can be implemented in different ways in Python. Some
possible methods include: base class, decorator, metaclass. We will use the
metaclass because it is best suited for this purpose.
"""
_instances: ClassVar[dict] = {}
def __call__(cls, *args, **kwargs):
"""
Possible changes to the value of the `__init__` argument do not affect
the returned instance.
"""
if cls not in cls._instances:
instance = super().__call__(*args, **kwargs)
cls._instances[cls] = instance
return cls._instances[cls]

View File

@@ -0,0 +1,63 @@
import base64
import pathlib
from Crypto.Cipher import AES
from Crypto.Util import Counter
def decrypt_security_token(security_token: str) -> (str, str):
"""
The `decrypt_security_token` function decrypts a security token into a key and nonce pair using AES
encryption.
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
master_key = "UIlTTEMmmLfGowo/UC60x2H45W6MdGgTRfo/umg4754="
# Decode the base64 strings to ascii strings
master_key = base64.b64decode(master_key)
security_token = base64.b64decode(security_token)
# Get the IV from the first 16 bytes of the securityToken
iv = security_token[:16]
encrypted_st = security_token[16:]
# Initialize decryptor
decryptor = AES.new(master_key, AES.MODE_CBC, iv)
# Decrypt the security token
decrypted_st = decryptor.decrypt(encrypted_st)
# Get the audio stream decryption key and nonce from the decrypted security token
key = decrypted_st[:16]
nonce = decrypted_st[16:24]
return key, nonce
def decrypt_file(path_file_encrypted: pathlib.Path, path_file_destination: pathlib.Path, key: str, nonce: str) -> None:
"""
Decrypts an encrypted MQA file given the file, key and nonce.
TODO: Is it really only necessary for MQA of for all other formats, too?
"""
# Initialize counter and file decryptor
counter = Counter.new(64, prefix=nonce, initial_value=0)
decryptor = AES.new(key, AES.MODE_CTR, counter=counter)
# Open and decrypt
with path_file_encrypted.open("rb") as f_src:
audio_decrypted = decryptor.decrypt(f_src.read())
# Replace with decrypted file
with path_file_destination.open("wb") as f_dst:
f_dst.write(audio_decrypted)

View File

@@ -0,0 +1,14 @@
class LoginError(Exception):
pass
class MediaUnknown(Exception):
pass
class UnknownManifestFormat(Exception):
pass
class MediaMissing(Exception):
pass

225
tidal_dl_ng/helper/gui.py Normal file
View File

@@ -0,0 +1,225 @@
import re
from typing import cast
from PySide6 import QtCore, QtGui, QtWidgets
from tidalapi import Album, Mix, Playlist, Track, UserPlaylist, Video
from tidalapi.artist import Artist
from tidalapi.media import Quality
from tidalapi.playlist import Folder
from tidal_dl_ng.constants import QualityVideo
def get_table_data(
item: QtWidgets.QTreeWidgetItem, column: int
) -> Track | Video | Album | Artist | Mix | Playlist | UserPlaylist:
result: Track | Video | Album | Artist = item.data(column, QtCore.Qt.ItemDataRole.UserRole)
return result
def get_table_text(item: QtWidgets.QTreeWidgetItem, column: int) -> str:
result: str = item.text(column)
return result
def get_results_media_item(
index: QtCore.QModelIndex, proxy: QtCore.QSortFilterProxyModel, model: QtGui.QStandardItemModel
) -> Track | Video | Album | Artist | Playlist | Mix:
# Switch column to "obj" column and map proxy data to our model.
item: QtGui.QStandardItem = model.itemFromIndex(proxy.mapToSource(index.siblingAtColumn(1)))
result: Track | Video | Album | Artist = item.data(QtCore.Qt.ItemDataRole.UserRole)
return result
def get_user_list_media_item(item: QtWidgets.QTreeWidgetItem) -> Mix | Playlist | UserPlaylist | Folder | str:
result: Mix | Playlist | UserPlaylist | Folder | str = get_table_data(item, 1)
return result
def get_queue_download_media(
item: QtWidgets.QTreeWidgetItem,
) -> Mix | Playlist | UserPlaylist | Track | Video | Album | Artist:
result: Mix | Playlist | UserPlaylist | Track | Video | Album | Artist = get_table_data(item, 1)
return result
def get_queue_download_quality(
item: QtWidgets.QTreeWidgetItem,
column: int,
) -> str:
result: str = get_table_text(item, column)
return result
def get_queue_download_quality_audio(
item: QtWidgets.QTreeWidgetItem,
) -> Quality:
result: Quality = cast(Quality, get_queue_download_quality(item, 4))
return result
def get_queue_download_quality_video(
item: QtWidgets.QTreeWidgetItem,
) -> QualityVideo:
result: QualityVideo = cast(QualityVideo, get_queue_download_quality(item, 5))
return result
def set_table_data(
item: QtWidgets.QTreeWidgetItem,
data: Track | Video | Album | Artist | Mix | Playlist | UserPlaylist | Folder | str,
column: int,
):
item.setData(column, QtCore.Qt.ItemDataRole.UserRole, data)
def set_results_media(item: QtWidgets.QTreeWidgetItem, media: Track | Video | Album | Artist):
set_table_data(item, media, 1)
def set_user_list_media(
item: QtWidgets.QTreeWidgetItem,
media: Track | Video | Album | Artist | Mix | Playlist | UserPlaylist | Folder | str,
):
set_table_data(item, media, 1)
def set_queue_download_media(
item: QtWidgets.QTreeWidgetItem, media: Mix | Playlist | UserPlaylist | Track | Video | Album | Artist
):
set_table_data(item, media, 1)
class FilterHeader(QtWidgets.QHeaderView):
filter_activated = QtCore.Signal()
def __init__(self, parent):
super().__init__(QtCore.Qt.Horizontal, parent)
self._editors = []
self._padding = 4
self.setCascadingSectionResizes(True)
self.setSectionResizeMode(QtWidgets.QHeaderView.Interactive)
self.setStretchLastSection(True)
self.setDefaultAlignment(QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter)
self.setSortIndicatorShown(False)
self.setSectionsMovable(True)
self.sectionResized.connect(self.adjust_positions)
parent.horizontalScrollBar().valueChanged.connect(self.adjust_positions)
def set_filter_boxes(self, count):
while self._editors:
editor = self._editors.pop()
editor.deleteLater()
for _ in range(count):
editor = QtWidgets.QLineEdit(self.parent())
editor.setPlaceholderText("Filter")
editor.setClearButtonEnabled(True)
editor.returnPressed.connect(self.filter_activated.emit)
self._editors.append(editor)
self.adjust_positions()
def sizeHint(self):
size = super().sizeHint()
if self._editors:
height = self._editors[0].sizeHint().height()
size.setHeight(size.height() + height + self._padding)
return size
def updateGeometries(self):
if self._editors:
height = self._editors[0].sizeHint().height()
self.setViewportMargins(0, 0, 0, height + self._padding)
else:
self.setViewportMargins(0, 0, 0, 0)
super().updateGeometries()
self.adjust_positions()
def adjust_positions(self):
for index, editor in enumerate(self._editors):
height = editor.sizeHint().height()
editor.move(self.sectionPosition(index) - self.offset() + 2, height + (self._padding // 2))
editor.resize(self.sectionSize(index), height)
def filter_text(self, index) -> str:
if 0 <= index < len(self._editors):
return self._editors[index].text()
return ""
def set_filter_text(self, index, text):
if 0 <= index < len(self._editors):
self._editors[index].setText(text)
def clear_filters(self):
for editor in self._editors:
editor.clear()
class HumanProxyModel(QtCore.QSortFilterProxyModel):
def _human_key(self, key):
parts = re.split(r"(\d*\.\d+|\d+)", key)
return tuple((e.swapcase() if i % 2 == 0 else float(e)) for i, e in enumerate(parts))
def lessThan(self, source_left, source_right):
data_left = source_left.data()
data_right = source_right.data()
if isinstance(data_left, str) and isinstance(data_right, str):
return self._human_key(data_left) < self._human_key(data_right)
return super().lessThan(source_left, source_right)
@property
def filters(self):
if not hasattr(self, "_filters"):
self._filters = []
return self._filters
@filters.setter
def filters(self, filters):
self._filters = filters
self.invalidateFilter()
def filterAcceptsRow(self, source_row: int, source_parent: QtCore.QModelIndex) -> bool:
model = self.sourceModel()
source_index = model.index(source_row, 0, source_parent)
result: [bool] = []
# Show top level children
for child_row in range(model.rowCount(source_index)):
if self.filterAcceptsRow(child_row, source_index):
return True
# Filter for actual needle
for i, text in self.filters:
if 0 <= i < self.columnCount():
ix = self.sourceModel().index(source_row, i, source_parent)
data = ix.data()
# Append results to list to enable an AND operator for filtering.
result.append(bool(re.search(rf"{text}", data, re.MULTILINE | re.IGNORECASE)) if data else False)
# If no filter set, just set the result to True.
if not result:
result.append(True)
return all(result)

691
tidal_dl_ng/helper/path.py Normal file
View File

@@ -0,0 +1,691 @@
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

274
tidal_dl_ng/helper/tidal.py Normal file
View File

@@ -0,0 +1,274 @@
from collections.abc import Callable
from tidalapi import Album, Mix, Playlist, Session, Track, UserPlaylist, Video
from tidalapi.artist import Artist, Role
from tidalapi.media import MediaMetadataTags, Quality
from tidalapi.session import SearchTypes
from tidalapi.user import LoggedInUser
from tidal_dl_ng.constants import FAVORITES, MediaType
from tidal_dl_ng.helper.exceptions import MediaUnknown
def name_builder_artist(media: Track | Video | Album, delimiter: str = ", ") -> str:
"""Builds a string of artist names for a track, video, or album.
Returns a delimited string of all artist names associated with the given media.
Args:
media (Track | Video | Album): The media object to extract artist names from.
delimiter (str, optional): The delimiter to use between artist names. Defaults to ", ".
Returns:
str: A delimited string of artist names.
"""
return delimiter.join(artist.name for artist in media.artists)
def name_builder_album_artist(media: Track | Album, first_only: bool = False, delimiter: str = ", ") -> str:
"""Builds a string of main album artist names for a track or album.
Returns a delimited string of main artist names from the album, optionally including only the first main artist.
Args:
media (Track | Album): The media object to extract artist names from.
first_only (bool, optional): If True, only the first main artist is included. Defaults to False.
delimiter (str, optional): The delimiter to use between artist names. Defaults to ", ".
Returns:
str: A delimited string of main album artist names.
"""
artists_tmp: [str] = []
artists: [Artist] = media.album.artists if isinstance(media, Track) else media.artists
for artist in artists:
if Role.main in artist.roles:
artists_tmp.append(artist.name)
if first_only:
break
return delimiter.join(artists_tmp)
def name_builder_title(media: Track | Video | Mix | Playlist | Album | Video) -> str:
result: str = (
media.title if isinstance(media, Mix) else media.full_name if hasattr(media, "full_name") else media.name
)
return result
def name_builder_item(media: Track | Video) -> str:
return f"{name_builder_artist(media)} - {name_builder_title(media)}"
def get_tidal_media_id(url_or_id_media: str) -> str:
id_dirty = url_or_id_media.rsplit("/", 1)[-1]
id_media = id_dirty.rsplit("?", 1)[0]
return id_media
def get_tidal_media_type(url_media: str) -> MediaType | bool:
result: MediaType | bool = False
url_split = url_media.split("/")[-2]
if len(url_split) > 1:
media_name = url_media.split("/")[-2]
if media_name == "track":
result = MediaType.TRACK
elif media_name == "video":
result = MediaType.VIDEO
elif media_name == "album":
result = MediaType.ALBUM
elif media_name == "playlist":
result = MediaType.PLAYLIST
elif media_name == "mix":
result = MediaType.MIX
elif media_name == "artist":
result = MediaType.ARTIST
return result
def url_ending_clean(url: str) -> str:
"""Checks if a link ends with "/u" or "?u" and removes that part.
Args:
url (str): The URL to clean.
Returns:
str: The cleaned URL.
"""
return url[:-2] if url.endswith("/u") or url.endswith("?u") else url
def search_results_all(session: Session, needle: str, types_media: SearchTypes = None) -> dict[str, [SearchTypes]]:
limit: int = 300
offset: int = 0
done: bool = False
result: dict[str, [SearchTypes]] = {}
while not done:
tmp_result: dict[str, [SearchTypes]] = session.search(
query=needle, models=types_media, limit=limit, offset=offset
)
tmp_done: bool = True
for key, value in tmp_result.items():
# Append pagination results, if there are any
if offset == 0:
result = tmp_result
tmp_done = False
elif bool(value):
result[key] += value
tmp_done = False
# Next page
offset += limit
done = tmp_done
return result
def items_results_all(
media_list: [Mix | Playlist | Album | Artist], videos_include: bool = True
) -> [Track | Video | Album]:
result: [Track | Video | Album] = []
if isinstance(media_list, Mix):
result = media_list.items()
else:
func_get_items_media: [Callable] = []
if isinstance(media_list, Playlist | Album):
if videos_include:
func_get_items_media.append(media_list.items)
else:
func_get_items_media.append(media_list.tracks)
else:
func_get_items_media.append(media_list.get_albums)
func_get_items_media.append(media_list.get_ep_singles)
result = paginate_results(func_get_items_media)
return result
def all_artist_album_ids(media_artist: Artist) -> [int | None]:
result: [int] = []
func_get_items_media: [Callable] = [media_artist.get_albums, media_artist.get_ep_singles]
albums: [Album] = paginate_results(func_get_items_media)
for album in albums:
result.append(album.id)
return result
def paginate_results(func_get_items_media: [Callable]) -> [Track | Video | Album | Playlist | UserPlaylist]:
result: [Track | Video | Album] = []
for func_media in func_get_items_media:
limit: int = 100
offset: int = 0
done: bool = False
if func_media.__func__ == LoggedInUser.playlist_and_favorite_playlists:
limit: int = 50
while not done:
tmp_result: [Track | Video | Album | Playlist | UserPlaylist] = func_media(limit=limit, offset=offset)
if bool(tmp_result):
result += tmp_result
# Get the next page in the next iteration.
offset += limit
else:
done = True
return result
def user_media_lists(session: Session) -> dict[str, list]:
"""Fetch user media lists using tidalapi's built-in pagination where available.
Returns a dictionary with 'playlists' and 'mixes' keys containing lists of media items.
For playlists, includes both Folder and Playlist objects at the root level.
Args:
session (Session): TIDAL session object.
Returns:
dict[str, list]: Dictionary with 'playlists' (includes Folder and Playlist) and 'mixes' lists.
"""
# Use built-in pagination for playlists (root level only)
playlists = session.user.favorites.playlists_paginated()
# Fetch root-level folders manually (no paginated version available)
folders = []
offset = 0
limit = 50
while True:
batch = session.user.favorites.playlist_folders(limit=limit, offset=offset, parent_folder_id="root")
if not batch:
break
folders.extend(batch)
if len(batch) < limit:
break
offset += limit
# Combine folders and playlists
all_playlists = folders + playlists
# Get mixes
user_mixes = session.mixes().categories[0].items
return {"playlists": all_playlists, "mixes": user_mixes}
def instantiate_media(
session: Session,
media_type: type[MediaType.TRACK, MediaType.VIDEO, MediaType.ALBUM, MediaType.PLAYLIST, MediaType.MIX],
id_media: str,
) -> Track | Video | Album | Playlist | Mix | Artist:
if media_type == MediaType.TRACK:
media = session.track(id_media, with_album=True)
elif media_type == MediaType.VIDEO:
media = session.video(id_media)
elif media_type == MediaType.ALBUM:
media = session.album(id_media)
elif media_type == MediaType.PLAYLIST:
media = session.playlist(id_media)
elif media_type == MediaType.MIX:
media = session.mix(id_media)
elif media_type == MediaType.ARTIST:
media = session.artist(id_media)
else:
raise MediaUnknown
return media
def quality_audio_highest(media: Track | Album) -> Quality:
quality: Quality
if MediaMetadataTags.hi_res_lossless in media.media_metadata_tags:
quality = Quality.hi_res_lossless
elif MediaMetadataTags.lossless in media.media_metadata_tags:
quality = Quality.high_lossless
else:
quality = media.audio_quality
return quality
def favorite_function_factory(tidal, favorite_item: str):
function_name: str = FAVORITES[favorite_item]["function_name"]
function_list: Callable = getattr(tidal.session.user.favorites, function_name)
return function_list

View File

@@ -0,0 +1,26 @@
from collections.abc import Callable
class LoggerWrapped:
fn_print: Callable = None
def __init__(self, fn_print: Callable):
self.fn_print = fn_print
def debug(self, value):
self.fn_print(value)
def warning(self, value):
self.fn_print(value)
def info(self, value):
self.fn_print(value)
def error(self, value):
self.fn_print(value)
def critical(self, value):
self.fn_print(value)
def exception(self, value):
self.fn_print(value)