update
This commit is contained in:
0
tidal_dl_ng/helper/__init__.py
Normal file
0
tidal_dl_ng/helper/__init__.py
Normal file
22
tidal_dl_ng/helper/decorator.py
Normal file
22
tidal_dl_ng/helper/decorator.py
Normal 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]
|
||||
63
tidal_dl_ng/helper/decryption.py
Normal file
63
tidal_dl_ng/helper/decryption.py
Normal 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)
|
||||
14
tidal_dl_ng/helper/exceptions.py
Normal file
14
tidal_dl_ng/helper/exceptions.py
Normal 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
225
tidal_dl_ng/helper/gui.py
Normal 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
691
tidal_dl_ng/helper/path.py
Normal 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
274
tidal_dl_ng/helper/tidal.py
Normal 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
|
||||
26
tidal_dl_ng/helper/wrapper.py
Normal file
26
tidal_dl_ng/helper/wrapper.py
Normal 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)
|
||||
Reference in New Issue
Block a user