summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.github/copilot-instructions.md36
-rw-r--r--AGENTS.md208
-rw-r--r--tidal_dl_ng/constants.py6
-rw-r--r--tidal_dl_ng/gui.py589
-rw-r--r--tidal_dl_ng/helper/gui.py12
-rw-r--r--tidal_dl_ng/helper/path.py5
-rw-r--r--tidal_dl_ng/helper/tidal.py40
7 files changed, 799 insertions, 97 deletions
diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
deleted file mode 100644
index f62d2fa..0000000
--- a/.github/copilot-instructions.md
+++ /dev/null
@@ -1,36 +0,0 @@
----
-applyTo: "**/*.py"
----
-
-# Project general coding standards
-
-## Naming Conventions
-
-- Use snake_case for variable and function names.
-- Use CamelCase for class names.
-- Follow PEP 8 style guidelines.
-- Prefix private class members with underscore (\_).
-- Use ALL_CAPS for constants.
-
-## Error Handling
-
-- Use try/except blocks for async operations.
-- Always log errors with contextual information.
-
-## Coding Guidelines
-
-- Use type annotations / hints for function and method parameters, return types and variables. Follow PEP 484.
-- Use the modern built-in generics from the `typing` module, such as `list`, `dict`, and `set`, instead of the older `List`, `Dict`, and `Set` from `typing`.
-- Use newest coding style which is supported by the used Python version.
-- Use more blank lines to achieve better code organization and readability.
-- Follow PEP 492 – Coroutines with async and await syntax
-- Follow PEP 498 – Literal String Interpolation
-- Follow PEP 572 – Assignment Expressions
-
-## Code Documentation
-
-- Always write doc strings for all modules, classes, functions, and methods using google docstring style.
-- Use typing in doc strings.
-- Use line comments to explain complex logic.
-- If refactoring code, ensure to update or add doc strings accordingly.
-- If refactoring code to not remove existing line comments, but to update them to reflect the new code logic.
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..df11974
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,208 @@
+---
+applyTo: "**/*.py"
+---
+
+# Project General Coding Standards
+
+## Python Version Support
+
+- Target Python 3.12 and 3.13
+- Use modern Python features supported by the minimum version (3.12)
+- Avoid deprecated features and use future-proof syntax
+
+## Naming Conventions
+
+- Use snake_case for variable and function names
+- Use CamelCase for class names
+- Follow PEP 8 style guidelines strictly
+- Prefix private class members with underscore (\_)
+- Use ALL_CAPS for constants
+- Use descriptive names that clearly indicate purpose and content
+- Avoid single-letter variable names except for loop counters or mathematical contexts
+
+## Type Annotations
+
+- Use type annotations for ALL function and method parameters, return types, and variables (PEP 484)
+- Use modern built-in generics: `list`, `dict`, `set`, `tuple` instead of `List`, `Dict`, `Set`, `Tuple` from `typing`
+- Use `None` type for optional parameters: `str | None` instead of `Optional[str]`
+- Use union types with `|` operator: `int | str` instead of `Union[int, str]`
+- For complex types, import from `collections.abc`: `Callable`, `Iterable`, etc.
+- Always specify generic types: use `list[str]` not just `list`
+- Use `pathlib.Path` for file paths, not `str`
+
+## Error Handling
+
+- Use try/except blocks for operations that may fail
+- Always log errors with contextual information using the project's logger
+- Catch specific exceptions, avoid bare `except:` clauses
+- Use `finally` blocks for cleanup operations
+- For HTTP operations with requests library:
+ - Use timeout parameter (default: `REQUESTS_TIMEOUT_SEC`)
+ - Implement retry logic with `requests.adapters.Retry` for network operations
+ - Always close response objects in `finally` blocks or use context managers
+- For file operations:
+ - Use context managers (`with` statement) for file handling
+ - Use `pathlib.Path` methods for path operations
+ - Handle `OSError` and its subclasses appropriately
+
+## Code Style and Formatting
+
+- Line length: maximum 120 characters (as configured in Black and Ruff)
+- Use Black formatting style with preview features enabled
+- Follow isort configuration for import ordering:
+ 1. FUTURE
+ 2. TYPING
+ 3. STDLIB
+ 4. THIRDPARTY
+ 5. FIRSTPARTY
+ 6. LOCALFOLDER
+- Include trailing commas in multi-line constructs
+- Use more blank lines to achieve better code organization and readability
+- Use 4 spaces for indentation (no tabs)
+
+## Modern Python Features
+
+- Follow PEP 492 – Coroutines with async and await syntax (when applicable)
+- Follow PEP 498 – Literal String Interpolation (f-strings)
+- Follow PEP 572 – Assignment Expressions (walrus operator `:=` when it improves readability)
+- Use structural pattern matching (match/case) for Python 3.10+ when appropriate
+- Prefer pathlib.Path over os.path for file operations
+- Use Enum and StrEnum for constants with related values
+- Use dataclasses or dataclasses-json for structured data
+
+## Concurrency and Threading
+
+- Use `concurrent.futures.ThreadPoolExecutor` for I/O-bound parallel operations
+- Always use context managers with executors
+- Set appropriate `max_workers` based on operation type (use configuration values)
+- Handle futures with `futures.as_completed()` for better responsiveness
+- Implement abort/cancellation mechanisms using `threading.Event`
+- Cancel pending futures when aborting operations
+- Use thread-safe data structures when sharing data between threads
+- Avoid blocking operations in GUI threads
+
+## Resource Management
+
+- Always use context managers (`with` statements) for:
+ - File operations
+ - Network connections
+ - Thread pools and executors
+ - Temporary directories and files
+- Use `tempfile.TemporaryDirectory` with `ignore_cleanup_errors=True` for temp operations
+- Close network responses explicitly in `finally` blocks or use context managers
+- Clean up temporary files after processing
+- Use `pathlib.Path.unlink(missing_ok=True)` for safe file deletion
+
+## File and Path Handling
+
+- Use `pathlib.Path` exclusively for path operations
+- Sanitize file paths using `pathvalidate.sanitize_filename` and project's `path_file_sanitize`
+- Use `.expanduser()` for paths that may contain `~`
+- Use `.absolute()` to get absolute paths
+- Use `.resolve()` to resolve symlinks
+- Check file existence with `Path.exists()`, `Path.is_file()`, `Path.is_dir()`
+- Use `os.makedirs(path, exist_ok=True)` or `Path.mkdir(parents=True, exist_ok=True)`
+- Handle cross-platform path differences automatically with pathlib
+
+## Code Documentation
+
+- Write docstrings for ALL modules, classes, functions, and methods using Google docstring style
+- Include type information in docstrings even when type hints are present
+- Document all parameters with their types and descriptions
+- Document return values with type and description
+- Document raised exceptions
+- Use line comments to explain complex logic, algorithms, or non-obvious decisions
+- When refactoring code:
+ - Update or add docstrings to reflect new behavior
+ - Update existing line comments rather than removing them
+ - Add TODO comments for known limitations or future improvements
+
+## Logging
+
+- Use the project's logger (via `fn_logger` or similar)
+- Log levels:
+ - `debug`: Detailed diagnostic information
+ - `info`: General informational messages (e.g., download completion)
+ - `error`: Error conditions with context
+ - `exception`: Errors with full traceback
+- Include relevant context in log messages (file names, IDs, URLs, etc.)
+- Use f-strings for log message formatting
+
+## GUI Development (PySide6/Qt)
+
+- Follow Qt naming conventions for slots and signals
+- Use type hints for signal parameters
+- Emit signals for cross-thread communication (never call GUI methods directly from worker threads)
+- Use Qt's threading mechanisms appropriately
+- Handle GUI progress updates via signals
+- Implement proper cleanup in close events
+- Use `QThread` or `ThreadPoolExecutor` for background operations, never block the GUI thread
+
+## API Integration (TIDAL)
+
+- Always check if media is available before processing (`media.available`)
+- Handle `tidalapi.exceptions.TooManyRequests` gracefully
+- Implement retry logic for transient failures
+- Use sessions appropriately
+- Handle stream manifests and encryption properly
+- Respect API rate limits and implement delays when configured
+
+## Testing
+
+- Place tests in the `tests/` directory
+- Use pytest as the testing framework
+- Use descriptive test function names: `test_<functionality_being_tested>`
+- Test edge cases: empty lists, None values, invalid inputs
+- Mock external dependencies (API calls, file system when appropriate)
+- Use fixtures for common test setup
+- Aim for meaningful test coverage, not just high percentages
+
+## Security
+
+- Never hardcode credentials (use configuration or environment variables)
+- Use base64 encoding only for obfuscation, not security
+- Handle sensitive data (tokens, keys) carefully
+- Use secure temporary file creation
+- Validate and sanitize all user inputs, especially file paths
+- Handle decryption keys securely (don't log them)
+
+## Performance
+
+- Use generators for large datasets when possible
+- Implement streaming for large file downloads
+- Use appropriate chunk sizes for file I/O (use `CHUNK_SIZE` constant)
+- Cache expensive computations when safe to do so
+- Use batch operations where applicable
+- Profile code before optimizing
+- Consider memory usage for large collections
+
+## Configuration and Settings
+
+- Use the Settings class for all configuration
+- Access settings via `self.settings.data.*`
+- Validate configuration values
+- Provide sensible defaults
+- Use type-safe configuration access
+- Document configuration options
+
+## Code Quality Tools
+
+- Run Ruff for linting before committing
+- Run Black for formatting before committing
+- Run mypy for type checking
+- Use pre-commit hooks to automate checks
+- Address all linting warnings and errors
+- Keep code complexity low (avoid deep nesting, long functions)
+
+## Best Practices Summary
+
+1. **Type Safety**: Always use type hints, enable strict mypy checks
+2. **Error Handling**: Catch specific exceptions, log with context, clean up resources
+3. **Readability**: Write self-documenting code with clear names and structure
+4. **Documentation**: Comprehensive docstrings and comments for complex logic
+5. **Testing**: Test edge cases and error conditions
+6. **Performance**: Use efficient algorithms and data structures
+7. **Maintainability**: Keep functions focused, avoid code duplication
+8. **Security**: Validate inputs, handle credentials safely
+9. **Compatibility**: Support Python 3.12-3.13, handle cross-platform differences
+10. **Standards**: Follow PEP 8, PEP 484, and project-specific configurations
diff --git a/tidal_dl_ng/constants.py b/tidal_dl_ng/constants.py
index 79ae501..696b62a 100644
--- a/tidal_dl_ng/constants.py
+++ b/tidal_dl_ng/constants.py
@@ -68,10 +68,10 @@ class QueueDownloadStatus(StrEnum):
FAVORITES: dict[str, dict[str, str]] = {
"fav_videos": {"name": "Videos", "function_name": "videos"},
- "fav_tracks": {"name": "Tracks", "function_name": "tracks"},
+ "fav_tracks": {"name": "Tracks", "function_name": "tracks_paginated"},
"fav_mixes": {"name": "Mixes & Radio", "function_name": "mixes"},
- "fav_artists": {"name": "Artists", "function_name": "artists"},
- "fav_albums": {"name": "Albums", "function_name": "albums"},
+ "fav_artists": {"name": "Artists", "function_name": "artists_paginated"},
+ "fav_albums": {"name": "Albums", "function_name": "albums_paginated"},
}
diff --git a/tidal_dl_ng/gui.py b/tidal_dl_ng/gui.py
index 7b83bd7..df5c3d7 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())):
@@ -565,13 +581,35 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow):
if not index.isValid() or not index.parent().data():
return
+ # Get the media item to determine type
+ item = self.tr_lists_user.itemAt(point)
+ media = get_user_list_media_item(item)
+
# We build the menu.
menu = QtWidgets.QMenu()
- menu.addAction("Download Playlist", lambda: self.thread_download_list_media(point))
- menu.addAction(
- "Download All Albums in Playlist", lambda: self.thread_it(self.on_download_all_albums_from_playlist, point)
- )
- menu.addAction("Copy Share URL", lambda: self.on_copy_url_share(self.tr_lists_user, point))
+
+ if isinstance(media, Folder):
+ # Folder-specific menu items
+ menu.addAction(
+ "Download All Playlists in Folder", lambda: self.thread_it(self.on_download_folder_playlists, point)
+ )
+ menu.addAction(
+ "Download All Albums from Folder", lambda: self.thread_it(self.on_download_folder_albums, point)
+ )
+ elif isinstance(media, str):
+ # Favorites items (stored as string keys like "fav_tracks", "fav_albums")
+ menu.addAction("Download All Items", lambda: self.thread_it(self.on_download_favorites, point))
+ menu.addAction(
+ "Download All Albums from Items", lambda: self.thread_it(self.on_download_albums_from_favorites, point)
+ )
+ else:
+ # Playlist/Mix menu items (existing)
+ menu.addAction("Download Playlist", lambda: self.thread_download_list_media(point))
+ menu.addAction(
+ "Download All Albums in Playlist",
+ lambda: self.thread_it(self.on_download_all_albums_from_playlist, point),
+ )
+ menu.addAction("Copy Share URL", lambda: self.on_copy_url_share(self.tr_lists_user, point))
menu.exec(self.tr_lists_user.mapToGlobal(point))
@@ -856,6 +894,270 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow):
if queue_dl_item:
self.queue_download_media(queue_dl_item)
+ def on_download_folder_playlists(self, point: QtCore.QPoint) -> None:
+ """Download all playlists in a folder.
+
+ Args:
+ point (QPoint): The point in the tree where the folder was right-clicked.
+ """
+ try:
+ # Get and validate the folder
+ item = self.tr_lists_user.itemAt(point)
+ media = get_user_list_media_item(item)
+
+ if not isinstance(media, Folder):
+ logger_gui.error("Please select a folder.")
+ return
+
+ # Fetch all playlists in the folder
+ logger_gui.info(f"Fetching playlists from folder: {media.name}")
+ playlists = self._get_folder_playlists(media)
+
+ if not playlists:
+ logger_gui.info(f"No playlists found in folder: {media.name}")
+ return
+
+ # Queue each playlist for download
+ logger_gui.info(f"Queueing {len(playlists)} playlists from folder: {media.name}")
+
+ for playlist in playlists:
+ queue_dl_item: QueueDownloadItem | None = self.media_to_queue_download_model(playlist)
+
+ if queue_dl_item:
+ self.queue_download_media(queue_dl_item)
+
+ logger_gui.info(f"✅ Successfully queued {len(playlists)} playlists from folder: {media.name}")
+
+ except Exception as e:
+ logger_gui.exception(f"Error downloading playlists from folder: {e}")
+ logger_gui.error("Failed to download playlists from folder. See log for details.")
+
+ def on_download_folder_albums(self, point: QtCore.QPoint) -> None:
+ """Download all unique albums from all playlists in a folder.
+
+ Args:
+ point (QPoint): The point in the tree where the folder was right-clicked.
+ """
+ try:
+ # Get and validate the folder
+ item = self.tr_lists_user.itemAt(point)
+ media = get_user_list_media_item(item)
+
+ if not isinstance(media, Folder):
+ logger_gui.error("Please select a folder.")
+ return
+
+ # Fetch all playlists in the folder
+ logger_gui.info(f"Fetching playlists from folder: {media.name}")
+ playlists = self._get_folder_playlists(media)
+
+ if not playlists:
+ logger_gui.info(f"No playlists found in folder: {media.name}")
+ return
+
+ logger_gui.info(f"Found {len(playlists)} playlists in folder: {media.name}")
+
+ # Collect all tracks from all playlists
+ all_tracks: list[Track] = []
+
+ for playlist in playlists:
+ try:
+ tracks = self._get_playlist_tracks(playlist)
+ all_tracks.extend(tracks)
+ logger_gui.debug(f"Collected {len(tracks)} tracks from playlist: {playlist.name}")
+ except Exception as e:
+ logger_gui.error(f"Error getting tracks from playlist '{playlist.name}': {e}")
+ continue
+
+ if not all_tracks:
+ logger_gui.info(f"No tracks found in folder playlists: {media.name}")
+ return
+
+ logger_gui.info(f"Collected {len(all_tracks)} total tracks from all playlists")
+
+ # Extract unique album IDs
+ album_ids = self._extract_album_ids_from_tracks(all_tracks)
+ logger_gui.info(f"Found {len(album_ids)} unique albums across all playlists in folder: {media.name}")
+
+ if not album_ids:
+ logger_gui.info("No albums found to download.")
+ return
+
+ # Load full album objects with rate limiting
+ albums_dict = self._load_albums_with_rate_limiting(album_ids)
+
+ if not albums_dict:
+ logger_gui.error("Failed to load any albums.")
+ return
+
+ # Queue the albums for download
+ self._queue_loaded_albums(albums_dict)
+
+ logger_gui.info(f"✅ Successfully queued {len(albums_dict)} unique albums from folder: {media.name}")
+
+ except Exception as e:
+ logger_gui.exception(f"Error downloading albums from folder: {e}")
+ logger_gui.error("Failed to download albums from folder. See log for details.")
+
+ def on_download_favorites(self, point: QtCore.QPoint) -> None:
+ """Download all items from a Favorites category.
+
+ Args:
+ point (QPoint): The point in the tree where the favorites item was right-clicked.
+ """
+ try:
+ # Get and validate the favorites item
+ item = self.tr_lists_user.itemAt(point)
+ media = get_user_list_media_item(item)
+
+ if not isinstance(media, str):
+ logger_gui.error("Please select a favorites category.")
+ return
+
+ # Get the favorites category name for logging
+ favorite_name = FAVORITES.get(media, {}).get("name", media)
+ logger_gui.info(f"Fetching all items from favorites: {favorite_name}")
+
+ # Use the factory to get the appropriate favorites function
+ favorite_function = favorite_function_factory(self.tidal, media)
+
+ # Fetch all items from this favorites category
+ media_items = favorite_function()
+
+ if not media_items:
+ logger_gui.info(f"No items found in favorites: {favorite_name}")
+ return
+
+ logger_gui.info(f"Found {len(media_items)} items in favorites: {favorite_name}")
+
+ # Queue each item for download
+ queued_count = 0
+
+ for media_item in media_items:
+ queue_dl_item: QueueDownloadItem | None = self.media_to_queue_download_model(media_item)
+
+ if queue_dl_item:
+ self.queue_download_media(queue_dl_item)
+ queued_count += 1
+
+ logger_gui.info(f"✅ Successfully queued {queued_count} items from favorites: {favorite_name}")
+
+ except Exception as e:
+ logger_gui.exception(f"Error downloading favorites: {e}")
+ logger_gui.error("Failed to download favorites. See log for details.")
+
+ def _download_albums_from_favorites_albums(self, media_items: list, favorite_name: str) -> None:
+ """Download albums from favorite albums list.
+
+ Args:
+ media_items (list): List of favorite albums.
+ favorite_name (str): Name of the favorites category for logging.
+ """
+ logger_gui.info(f"Queueing {len(media_items)} albums from favorites: {favorite_name}")
+ albums_dict = {album.id: album for album in media_items if isinstance(album, Album) and album.id}
+ self._queue_loaded_albums(albums_dict)
+ logger_gui.info(f"✅ Successfully queued {len(albums_dict)} albums from favorites: {favorite_name}")
+
+ def _download_albums_from_favorites_artists(self, media_items: list, favorite_name: str) -> None:
+ """Download albums from favorite artists list.
+
+ Args:
+ media_items (list): List of favorite artists.
+ favorite_name (str): Name of the favorites category for logging.
+ """
+ logger_gui.info(f"Fetching albums from {len(media_items)} artists...")
+ all_albums = {}
+
+ for artist in media_items:
+ if isinstance(artist, Artist):
+ try:
+ artist_albums = items_results_all(artist)
+ for album in artist_albums:
+ if isinstance(album, Album) and album.id:
+ all_albums[album.id] = album
+ logger_gui.debug(f"Found {len(artist_albums)} albums from artist: {artist.name}")
+ except Exception as e:
+ logger_gui.error(f"Error getting albums from artist '{artist.name}': {e}")
+ continue
+
+ if not all_albums:
+ logger_gui.info("No albums found from favorite artists.")
+ return
+
+ logger_gui.info(f"Found {len(all_albums)} unique albums from favorite artists")
+ self._queue_loaded_albums(all_albums)
+ logger_gui.info(f"✅ Successfully queued {len(all_albums)} albums from favorites: {favorite_name}")
+
+ def _download_albums_from_favorites_tracks(self, media_items: list, favorite_name: str) -> None:
+ """Download albums from favorite tracks/videos/mixes list.
+
+ Args:
+ media_items (list): List of favorite tracks/videos/mixes.
+ favorite_name (str): Name of the favorites category for logging.
+ """
+ logger_gui.info("Extracting albums from tracks...")
+ album_ids = self._extract_album_ids_from_tracks(media_items)
+
+ if not album_ids:
+ logger_gui.info(f"No albums found in favorites: {favorite_name}")
+ return
+
+ logger_gui.info(f"Found {len(album_ids)} unique albums. Loading with rate limiting...")
+
+ # Load full album objects with rate limiting
+ albums_dict = self._load_albums_with_rate_limiting(album_ids)
+
+ if not albums_dict:
+ logger_gui.error("Failed to load any albums from favorites.")
+ return
+
+ # Queue the albums for download
+ self._queue_loaded_albums(albums_dict)
+ logger_gui.info(f"✅ Successfully queued {len(albums_dict)} unique albums from favorites: {favorite_name}")
+
+ def on_download_albums_from_favorites(self, point: QtCore.QPoint) -> None:
+ """Download all unique albums from items in a Favorites category.
+
+ Args:
+ point (QPoint): The point in the tree where the favorites item was right-clicked.
+ """
+ try:
+ # Get and validate the favorites item
+ item = self.tr_lists_user.itemAt(point)
+ media = get_user_list_media_item(item)
+
+ if not isinstance(media, str):
+ logger_gui.error("Please select a favorites category.")
+ return
+
+ # Get the favorites category name for logging
+ favorite_name = FAVORITES.get(media, {}).get("name", media)
+ logger_gui.info(f"Fetching all items from favorites: {favorite_name}")
+
+ # Use the factory to get the appropriate favorites function
+ favorite_function = favorite_function_factory(self.tidal, media)
+
+ # Fetch all items from this favorites category
+ media_items = favorite_function()
+
+ if not media_items:
+ logger_gui.info(f"No items found in favorites: {favorite_name}")
+ return
+
+ logger_gui.info(f"Found {len(media_items)} items in favorites: {favorite_name}")
+
+ # Delegate to appropriate handler based on favorites type
+ if media == "fav_albums":
+ self._download_albums_from_favorites_albums(media_items, favorite_name)
+ elif media == "fav_artists":
+ self._download_albums_from_favorites_artists(media_items, favorite_name)
+ else:
+ self._download_albums_from_favorites_tracks(media_items, favorite_name)
+
+ except Exception as e:
+ logger_gui.exception(f"Error downloading albums from favorites: {e}")
+ logger_gui.error("Failed to download albums from favorites. See log for details.")
+
def search_populate_results(self, query: str, type_media: Any) -> None:
"""Populate the results tree with search results.
@@ -1026,38 +1328,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)
+ date_release = self._get_date_release(item)
- if isinstance(item, Playlist):
- return self._result_item_from_playlist(idx, item, 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, Album):
- return self._result_item_from_album(idx, item, explicit, date_user_added, date_release)
-
- 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 +1537,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 +1640,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 +1649,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 +1745,156 @@ 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 _get_folder_playlists(self, folder: Folder) -> list[Playlist]:
+ """Fetch all playlists from a folder.
+
+ Args:
+ folder (Folder): The folder to fetch playlists from.
+
+ Returns:
+ list[Playlist]: List of playlists in the folder.
+ """
+ # Use existing method to fetch folder contents
+ # Since folders can't contain folders, we ignore the folders return value
+ _, playlists = self._fetch_folder_contents(folder)
+
+ logger_gui.debug(f"Found {len(playlists)} playlists in folder: {folder.name}")
+
+ return playlists
+
+ def _get_playlist_tracks(self, playlist: Playlist | UserPlaylist | Mix) -> list[Track]:
+ """Fetch all tracks from a playlist.
+
+ Args:
+ playlist (Playlist | UserPlaylist | Mix): The playlist to fetch tracks from.
+
+ Returns:
+ list[Track]: List of tracks in the playlist.
+ """
+ playlist_name = getattr(playlist, "name", "unknown")
+ logger_gui.debug(f"Fetching tracks from playlist: {playlist_name}")
+ media_items = items_results_all(playlist)
+
+ # Filter for Track objects only (items_results_all may return Videos too)
+ tracks = [item for item in media_items if isinstance(item, Track)]
+
+ logger_gui.debug(f"Found {len(tracks)} tracks in playlist: {playlist_name}")
+
+ return tracks
+
def on_list_items_show(self, item: QtWidgets.QTreeWidgetItem) -> None:
"""Show the items in the selected playlist or mix.
@@ -1432,19 +1904,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 +1929,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/path.py b/tidal_dl_ng/helper/path.py
index 9227ee3..6ed8020 100644
--- a/tidal_dl_ng/helper/path.py
+++ b/tidal_dl_ng/helper/path.py
@@ -452,6 +452,11 @@ def _format_metadata(
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
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(