summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorexislow <exislow@users.noreply.github.com>2025-11-12 15:06:18 +0100
committerGitHub <noreply@github.com>2025-11-12 15:06:18 +0100
commitbe59cf1684405fcef475fb8b774ff86e2a8df042 (patch)
tree7df1f6eb74b2896808d800ef94d0b24358e81456
parent554a42860e666f85ac99df6c08c10b3a6f16a5be (diff)
parent9baea9b82c539eae693b11416aeb74db434b86ee (diff)
downloadtidal-dl-ng-be59cf1684405fcef475fb8b774ff86e2a8df042.tar.gz
Merge pull request #644 from exislow:folders
feat(gui): ✨ enhance user media handling with folder support
-rw-r--r--tidal_dl_ng/gui.py256
-rw-r--r--tidal_dl_ng/helper/gui.py12
-rw-r--r--tidal_dl_ng/helper/tidal.py40
3 files changed, 255 insertions, 53 deletions
diff --git a/tidal_dl_ng/gui.py b/tidal_dl_ng/gui.py
index 7b83bd7..074bd19 100644
--- a/tidal_dl_ng/gui.py
+++ b/tidal_dl_ng/gui.py
@@ -95,6 +95,7 @@ from rich.progress import Progress
from tidalapi import Album, Mix, Playlist, Quality, Track, UserPlaylist, Video
from tidalapi.artist import Artist
from tidalapi.media import AudioMode
+from tidalapi.playlist import Folder
from tidalapi.session import SearchTypes
from tidal_dl_ng.config import HandlingApp, Settings, Tidal
@@ -134,7 +135,8 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow):
pb_list: QtWidgets.QProgressBar
s_list_advance: QtCore.Signal = QtCore.Signal(float)
s_pb_reset: QtCore.Signal = QtCore.Signal()
- s_populate_tree_lists: QtCore.Signal = QtCore.Signal(list)
+ s_populate_tree_lists: QtCore.Signal = QtCore.Signal(dict)
+ s_populate_folder_children: QtCore.Signal = QtCore.Signal(object, list, list)
s_statusbar_message: QtCore.Signal = QtCore.Signal(object)
s_tr_results_add_top_level_item: QtCore.Signal = QtCore.Signal(object)
s_settings_save: QtCore.Signal = QtCore.Signal()
@@ -412,15 +414,15 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow):
self.s_spinner_start.emit(self.tr_lists_user)
self.s_pb_reload_status.emit(False)
- user_all: list[Any] = user_media_lists(self.tidal.session)
+ user_all: dict[str, list] = user_media_lists(self.tidal.session)
self.s_populate_tree_lists.emit(user_all)
- def on_populate_tree_lists(self, user_lists: list[Playlist | UserPlaylist | Mix]) -> None:
+ def on_populate_tree_lists(self, user_lists: dict[str, list]) -> None:
"""Populate the user lists tree with playlists, mixes, and favorites.
Args:
- user_lists (list[Playlist | UserPlaylist | Mix]): List of user playlists, mixes, and favorites.
+ user_lists (dict[str, list]): Dictionary with 'playlists' (Folder/Playlist) and 'mixes' lists.
"""
twi_playlists: QtWidgets.QTreeWidgetItem = self.tr_lists_user.findItems(
TidalLists.Playlists, QtCore.Qt.MatchExactly, 0
@@ -437,23 +439,37 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow):
for i in reversed(range(twi.childCount())):
twi.removeChild(twi.child(i))
- # Populate dynamic user lists
- for item in user_lists:
- if isinstance(item, UserPlaylist | Playlist):
+ # Populate playlists (including folders)
+ for item in user_lists.get("playlists", []):
+ if isinstance(item, Folder):
+ twi_child = QtWidgets.QTreeWidgetItem(twi_playlists)
+ name: str = f"📁 {item.name}"
+ info: str = f"({item.total_number_of_items} items)" if item.total_number_of_items else ""
+ twi_child.setText(0, name)
+ set_user_list_media(twi_child, item)
+ twi_child.setText(2, info)
+
+ # Add disabled dummy child to show expansion arrow
+ dummy_child = QtWidgets.QTreeWidgetItem(twi_child)
+ dummy_child.setDisabled(True)
+ elif isinstance(item, UserPlaylist | Playlist):
twi_child = QtWidgets.QTreeWidgetItem(twi_playlists)
name: str = item.name if getattr(item, "name", None) is not None else ""
description: str = f" {item.description}" if item.description else ""
info: str = f"({item.num_tracks + item.num_videos} Tracks){description}"
- elif isinstance(item, Mix):
+ twi_child.setText(0, name)
+ set_user_list_media(twi_child, item)
+ twi_child.setText(2, info)
+
+ # Populate mixes
+ for item in user_lists.get("mixes", []):
+ if isinstance(item, Mix):
twi_child = QtWidgets.QTreeWidgetItem(twi_mixes)
name: str = item.title
info: str = item.sub_title
- else:
- continue
-
- twi_child.setText(0, name)
- set_user_list_media(twi_child, item)
- twi_child.setText(2, info)
+ twi_child.setText(0, name)
+ set_user_list_media(twi_child, item)
+ twi_child.setText(2, info)
# Remove all children from favorites to avoid duplication
for i in reversed(range(twi_favorites.childCount())):
@@ -1026,38 +1042,31 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow):
Returns:
ResultItem | None: The converted ResultItem or None if not valid.
"""
- if not item:
- return None
-
- if hasattr(item, "available") and not item.available:
+ if not item or (hasattr(item, "available") and not item.available):
return None
- explicit: str = ""
- if isinstance(item, Track | Video | Album):
- explicit = " 🅴" if item.explicit else ""
-
- date_user_added: str = (
+ # Prepare common data
+ explicit = " 🅴" if isinstance(item, Track | Video | Album) and item.explicit else ""
+ date_user_added = (
item.user_date_added.strftime("%Y-%m-%d_%H:%M") if getattr(item, "user_date_added", None) else ""
)
- date_release: str = self._get_date_release(item)
-
- if isinstance(item, Track):
- return self._result_item_from_track(idx, item, explicit, date_user_added, date_release)
-
- if isinstance(item, Video):
- return self._result_item_from_video(idx, item, explicit, date_user_added, date_release)
-
- if isinstance(item, Playlist):
- return self._result_item_from_playlist(idx, item, date_user_added, date_release)
+ date_release = self._get_date_release(item)
- if isinstance(item, Album):
- return self._result_item_from_album(idx, item, explicit, date_user_added, date_release)
+ # Map item types to their conversion methods
+ type_handlers = {
+ Track: lambda: self._result_item_from_track(idx, item, explicit, date_user_added, date_release),
+ Video: lambda: self._result_item_from_video(idx, item, explicit, date_user_added, date_release),
+ Playlist: lambda: self._result_item_from_playlist(idx, item, date_user_added, date_release),
+ Album: lambda: self._result_item_from_album(idx, item, explicit, date_user_added, date_release),
+ Mix: lambda: self._result_item_from_mix(idx, item, date_user_added, date_release),
+ Artist: lambda: self._result_item_from_artist(idx, item, date_user_added, date_release),
+ Folder: lambda: self._result_item_from_folder(idx, item, date_user_added),
+ }
- if isinstance(item, Mix):
- return self._result_item_from_mix(idx, item, date_user_added, date_release)
-
- if isinstance(item, Artist):
- return self._result_item_from_artist(idx, item, date_user_added, date_release)
+ # Find and execute the appropriate handler
+ for item_type, handler in type_handlers.items():
+ if isinstance(item, item_type):
+ return handler()
return None
@@ -1242,6 +1251,31 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow):
date_release=date_release,
)
+ def _result_item_from_folder(self, idx: int, item: Folder, date_user_added: str) -> ResultItem:
+ """Create a ResultItem from a Folder.
+
+ Args:
+ idx (int): Index of the item.
+ item (Folder): The Folder item.
+ date_user_added (str): Date user added.
+
+ Returns:
+ ResultItem: The constructed ResultItem.
+ """
+ total_items: int = item.total_number_of_items if hasattr(item, "total_number_of_items") else 0
+ return ResultItem(
+ position=idx,
+ artist="",
+ title=f"📁 {item.name} ({total_items} items)",
+ album="",
+ duration_sec=-1,
+ obj=item,
+ quality="",
+ explicit=False,
+ date_user_added=date_user_added,
+ date_release="",
+ )
+
def media_to_queue_download_model(
self, media: Artist | Track | Video | Album | Playlist | Mix
) -> QueueDownloadItem | bool:
@@ -1320,6 +1354,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow):
self.cb_quality_audio.currentIndexChanged.connect(self.on_quality_set_audio)
self.cb_quality_video.currentIndexChanged.connect(self.on_quality_set_video)
self.tr_lists_user.itemClicked.connect(self.on_list_items_show)
+ self.tr_lists_user.itemExpanded.connect(self.on_tr_lists_user_expanded)
self.s_spinner_start[QtWidgets.QWidget].connect(self.on_spinner_start)
self.s_spinner_stop.connect(self.on_spinner_stop)
self.s_item_advance.connect(self.on_progress_item)
@@ -1328,6 +1363,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow):
self.s_list_advance.connect(self.on_progress_list)
self.s_pb_reset.connect(self.on_progress_reset)
self.s_populate_tree_lists.connect(self.on_populate_tree_lists)
+ self.s_populate_folder_children.connect(self.on_populate_folder_children)
self.s_statusbar_message.connect(self.on_statusbar_message)
self.s_tr_results_add_top_level_item.connect(self.on_tr_results_add_top_level_item)
self.s_settings_save.connect(self.on_settings_save)
@@ -1423,6 +1459,119 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow):
if self.tidal:
self.tidal.settings_apply()
+ def on_tr_lists_user_expanded(self, item: QtWidgets.QTreeWidgetItem) -> None:
+ """Handle expansion of folders in the user lists tree.
+
+ Args:
+ item (QTreeWidgetItem): The expanded tree item.
+ """
+ # Check if it's a first-time expansion (has disabled dummy child)
+ if item.childCount() > 0 and item.child(0).isDisabled():
+ # Run in thread to avoid blocking UI
+ self.thread_it(self.tr_lists_user_load_folder_children, item)
+
+ def tr_lists_user_load_folder_children(self, parent_item: QtWidgets.QTreeWidgetItem) -> None:
+ """Load and display children of a folder in the user lists tree.
+
+ Args:
+ parent_item (QTreeWidgetItem): The parent folder item.
+ """
+ folder: Folder | None = get_user_list_media_item(parent_item)
+
+ if not isinstance(folder, Folder):
+ return
+
+ # Show spinner while loading
+ self.s_spinner_start.emit(self.tr_lists_user)
+
+ try:
+ # Fetch folder contents
+ folders, playlists = self._fetch_folder_contents(folder)
+
+ # Emit signal to populate in main thread
+ self.s_populate_folder_children.emit(parent_item, folders, playlists)
+
+ finally:
+ self.s_spinner_stop.emit()
+
+ def on_populate_folder_children(
+ self, parent_item: QtWidgets.QTreeWidgetItem, folders: list[Folder], playlists: list[Playlist]
+ ) -> None:
+ """Populate folder children in the main thread (signal handler).
+
+ Args:
+ parent_item (QTreeWidgetItem): The parent folder item.
+ folders (list[Folder]): List of sub-folders.
+ playlists (list[Playlist]): List of playlists.
+ """
+ # Remove dummy child
+ parent_item.takeChild(0)
+
+ # Add sub-folders as children
+ for sub_folder in folders:
+ twi_child = QtWidgets.QTreeWidgetItem(parent_item)
+ twi_child.setText(0, f"📁 {sub_folder.name}")
+ set_user_list_media(twi_child, sub_folder)
+ info = f"({sub_folder.total_number_of_items} items)" if sub_folder.total_number_of_items else ""
+ twi_child.setText(2, info)
+
+ # Add dummy child for potential sub-folders
+ dummy = QtWidgets.QTreeWidgetItem(twi_child)
+ dummy.setDisabled(True)
+
+ # Add playlists as children
+ for playlist in playlists:
+ twi_child = QtWidgets.QTreeWidgetItem(parent_item)
+ name = playlist.name if playlist.name else ""
+ twi_child.setText(0, name)
+ set_user_list_media(twi_child, playlist)
+ info = f"({playlist.num_tracks + playlist.num_videos} Tracks)"
+ if playlist.description:
+ info += f" {playlist.description}"
+ twi_child.setText(2, info)
+
+ def _fetch_folder_contents(self, folder: Folder) -> tuple[list[Folder], list[Playlist]]:
+ """Fetch contents (sub-folders and playlists) of a folder.
+
+ Args:
+ folder (Folder): The folder to fetch contents for.
+
+ Returns:
+ tuple[list[Folder], list[Playlist]]: Sub-folders and playlists within the folder.
+ """
+ folder_id = folder.id if folder.id else "root"
+
+ # Fetch sub-folders with manual pagination
+ offset = 0
+ limit = 50
+ folders = []
+
+ while True:
+ batch = self.tidal.session.user.favorites.playlist_folders(
+ limit=limit, offset=offset, parent_folder_id=folder_id
+ )
+ if not batch:
+ break
+ folders.extend(batch)
+ if len(batch) < limit:
+ break
+ offset += limit
+
+ # Fetch playlists in this folder using folder.items() method
+ offset = 0
+ playlists = []
+
+ while True:
+ batch = folder.items(offset=offset, limit=limit)
+ if not batch:
+ break
+ playlists.extend(batch)
+ if len(batch) < limit:
+ break
+ offset += limit
+
+ return folders, playlists
+
def on_list_items_show(self, item: QtWidgets.QTreeWidgetItem) -> None:
"""Show the items in the selected playlist or mix.
@@ -1432,19 +1581,22 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow):
self.thread_it(self.list_items_show, item)
def list_items_show(self, item: QtWidgets.QTreeWidgetItem) -> None:
- """Fetch and display the items in a playlist or mix.
+ """Fetch and display the items in a playlist, mix, or folder.
Args:
- item (QtWidgets.QTreeWidgetItem): The tree widget item representing a playlist or mix.
+ item (QtWidgets.QTreeWidgetItem): The tree widget item representing a playlist, mix, or folder.
"""
- media_list: Album | Playlist | str = get_user_list_media_item(item)
+ media_list: Album | Playlist | Folder | str = get_user_list_media_item(item)
# Only if clicked item is not a top level item.
if media_list:
# Show spinner while loading list
self.s_spinner_start.emit(self.tr_results)
try:
- if isinstance(media_list, str) and media_list.startswith("fav_"):
+ if isinstance(media_list, Folder):
+ # Show folder contents
+ self._show_folder_contents(media_list)
+ elif isinstance(media_list, str) and media_list.startswith("fav_"):
function_list = favorite_function_factory(self.tidal, media_list)
self.list_items_show_result(favorite_function=function_list)
else:
@@ -1454,6 +1606,22 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow):
finally:
self.s_spinner_stop.emit()
+ def _show_folder_contents(self, folder: Folder) -> None:
+ """Display folder contents (nested playlists/folders) in results pane.
+
+ Args:
+ folder (Folder): The folder to display contents for.
+ """
+ # Fetch folder contents using the shared helper method
+ folders, playlists = self._fetch_folder_contents(folder)
+
+ # Combine folders and playlists
+ items = folders + playlists
+
+ # Convert to ResultItems and display
+ result = self.search_result_to_model(items)
+ self.populate_tree_results(result)
+
def on_result_item_clicked(self, index: QtCore.QModelIndex) -> None:
"""Handle the event when a result item is clicked.
diff --git a/tidal_dl_ng/helper/gui.py b/tidal_dl_ng/helper/gui.py
index d680b19..35a517f 100644
--- a/tidal_dl_ng/helper/gui.py
+++ b/tidal_dl_ng/helper/gui.py
@@ -5,6 +5,7 @@ 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
@@ -33,8 +34,8 @@ def get_results_media_item(
return result
-def get_user_list_media_item(item: QtWidgets.QTreeWidgetItem) -> Mix | Playlist | UserPlaylist:
- result: Mix | Playlist | UserPlaylist = get_table_data(item, 1)
+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
@@ -73,7 +74,9 @@ def get_queue_download_quality_video(
def set_table_data(
- item: QtWidgets.QTreeWidgetItem, data: Track | Video | Album | Artist | Mix | Playlist | UserPlaylist, column: int
+ item: QtWidgets.QTreeWidgetItem,
+ data: Track | Video | Album | Artist | Mix | Playlist | UserPlaylist | Folder | str,
+ column: int,
):
item.setData(column, QtCore.Qt.ItemDataRole.UserRole, data)
@@ -83,7 +86,8 @@ def set_results_media(item: QtWidgets.QTreeWidgetItem, media: Track | Video | Al
def set_user_list_media(
- item: QtWidgets.QTreeWidgetItem, media: Track | Video | Album | Artist | Mix | Playlist | UserPlaylist
+ item: QtWidgets.QTreeWidgetItem,
+ media: Track | Video | Album | Artist | Mix | Playlist | UserPlaylist | Folder | str,
):
set_table_data(item, media, 1)
diff --git a/tidal_dl_ng/helper/tidal.py b/tidal_dl_ng/helper/tidal.py
index 458af49..586e196 100644
--- a/tidal_dl_ng/helper/tidal.py
+++ b/tidal_dl_ng/helper/tidal.py
@@ -193,12 +193,42 @@ def paginate_results(func_get_items_media: [Callable]) -> [Track | Video | Album
return result
-def user_media_lists(session: Session) -> [Playlist | UserPlaylist | Mix]:
- user_playlists: [Playlist | UserPlaylist] = paginate_results([session.user.playlist_and_favorite_playlists])
- user_mixes: [Mix] = session.mixes().categories[0].items
- result: [Playlist | UserPlaylist | Mix] = user_playlists + user_mixes
+def user_media_lists(session: Session) -> dict[str, list]:
+ """Fetch user media lists using tidalapi's built-in pagination where available.
- return result
+ 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(