summaryrefslogtreecommitdiff
path: root/tidal_dl_ng/gui/context_menus.py
diff options
context:
space:
mode:
authorWarry <FunWarry@users.noreply.github.com>2025-12-04 12:03:31 +0100
committerWarry <FunWarry@users.noreply.github.com>2025-12-04 12:03:31 +0100
commit75868b32d07aafe46b69649d2da0dc2c833f112d (patch)
tree970a50f4cbf141af0e5e0856e087e347f3b5be48 /tidal_dl_ng/gui/context_menus.py
parent428d34056be16ed9d28597253b333dfbd7f21a47 (diff)
downloadtidal-dl-ng-75868b32d07aafe46b69649d2da0dc2c833f112d.tar.gz
refactor: Restructure GUI components into separate modules for improved maintainability
Diffstat (limited to 'tidal_dl_ng/gui/context_menus.py')
-rw-r--r--tidal_dl_ng/gui/context_menus.py278
1 files changed, 278 insertions, 0 deletions
diff --git a/tidal_dl_ng/gui/context_menus.py b/tidal_dl_ng/gui/context_menus.py
new file mode 100644
index 0000000..825a3ca
--- /dev/null
+++ b/tidal_dl_ng/gui/context_menus.py
@@ -0,0 +1,278 @@
+"""Context menus mixin for MainWindow.
+
+Handles context menu creation and actions.
+"""
+
+import time
+import urllib.parse
+
+from PySide6 import QtCore, QtGui, QtWidgets
+from tidalapi import Album, Artist, Mix, Playlist, Track, UserPlaylist, Video
+
+from tidal_dl_ng.constants import QueueDownloadStatus
+from tidal_dl_ng.helper.gui import get_results_media_item, get_user_list_media_item
+from tidal_dl_ng.helper.tidal import items_results_all, name_builder_artist
+from tidal_dl_ng.logger import logger_gui
+from tidal_dl_ng.model.gui_data import StatusbarMessage
+
+
+class ContextMenusMixin:
+ """Mixin containing context menu methods."""
+
+ def menu_context_tree_results(self, point: QtCore.QPoint) -> None:
+ """Show context menu for results tree."""
+ index = self.tr_results.indexAt(point)
+
+ if not index.isValid():
+ return
+
+ media = get_results_media_item(index, self.proxy_tr_results, self.model_tr_results)
+
+ menu = QtWidgets.QMenu()
+
+ if isinstance(media, Track | Video) and hasattr(media, "album") and media.album:
+ menu.addAction("Download Full Album", lambda: self.thread_download_album_from_track(point))
+
+ if isinstance(media, Track):
+ track_id = str(media.id)
+ is_downloaded = self.history_service.is_downloaded(track_id)
+
+ if is_downloaded:
+ menu.addAction(
+ "✖️ Mark as Not Downloaded", lambda: self.on_mark_track_as_not_downloaded(track_id, index)
+ )
+ else:
+ menu.addAction("✅ Mark as Downloaded", lambda: self.on_mark_track_as_downloaded(media, index))
+
+ menu.addAction("Copy Share URL", lambda: self.on_copy_url_share(self.tr_results, point))
+
+ menu.exec(self.tr_results.mapToGlobal(point))
+
+ def menu_context_queue_download(self, point: QtCore.QPoint) -> None:
+ """Show context menu for download queue."""
+ item = self.tr_queue_download.itemAt(point)
+
+ if not item:
+ return
+
+ menu = QtWidgets.QMenu()
+
+ status = item.text(0)
+ if status == QueueDownloadStatus.Waiting:
+ menu.addAction("🗑️ Remove from Queue", lambda: self.on_queue_download_remove_item(item))
+
+ if menu.isEmpty():
+ return
+
+ menu.exec(self.tr_queue_download.mapToGlobal(point))
+
+ def on_queue_download_remove_item(self, item: QtWidgets.QTreeWidgetItem) -> None:
+ """Remove a specific item from the download queue."""
+ index = self.tr_queue_download.indexOfTopLevelItem(item)
+ if index >= 0:
+ self.tr_queue_download.takeTopLevelItem(index)
+ logger_gui.info("Removed item from download queue")
+
+ def on_copy_url_share(
+ self, tree_target: QtWidgets.QTreeWidget | QtWidgets.QTreeView, point: QtCore.QPoint = None
+ ) -> None:
+ """Copy the share URL of a media item to the clipboard."""
+ if isinstance(tree_target, QtWidgets.QTreeWidget):
+ item: QtWidgets.QTreeWidgetItem = tree_target.itemAt(point)
+ media = get_user_list_media_item(item)
+ else:
+ index: QtCore.QModelIndex = tree_target.indexAt(point)
+ media = get_results_media_item(index, self.proxy_tr_results, self.model_tr_results)
+
+ clipboard = QtWidgets.QApplication.clipboard()
+ url_share = media.share_url if hasattr(media, "share_url") else "No share URL available."
+
+ clipboard.clear()
+ clipboard.setText(url_share)
+
+ def thread_download_list_media(self, point: QtCore.QPoint) -> None:
+ """Start download of a list media item in a thread."""
+ self.thread_it(self.playlist_manager.on_download_list_media, point)
+
+ def thread_download_album_from_track(self, point: QtCore.QPoint) -> None:
+ """Starts the download of the full album from a selected track in a new thread."""
+ self.thread_it(self.on_download_album_from_track, point)
+
+ def on_download_album_from_track(self, point: QtCore.QPoint) -> None:
+ """Adds the album associated with a selected track to the download queue."""
+ index: QtCore.QModelIndex = self.tr_results.indexAt(point)
+ media_track: Track = get_results_media_item(index, self.proxy_tr_results, self.model_tr_results)
+
+ if isinstance(media_track, Track) and media_track.album and media_track.album.id:
+ try:
+ full_album_object = self.tidal.session.album(media_track.album.id)
+
+ queue_dl_item = self.search_manager.media_to_queue_download_model(full_album_object)
+
+ if queue_dl_item:
+ self.queue_download_media(queue_dl_item)
+ else:
+ logger_gui.warning(f"Failed to create a queue item for album ID: {full_album_object.id}")
+ except Exception as e:
+ logger_gui.error(f"Could not fetch the full album from TIDAL. Error: {e}")
+ else:
+ logger_gui.warning("Could not retrieve album information from the selected track.")
+
+ def on_download_all_albums_from_playlist(self, point: QtCore.QPoint) -> None:
+ """Download all unique albums from tracks in a playlist."""
+ try:
+ item = self.tr_lists_user.itemAt(point)
+ media_list = get_user_list_media_item(item)
+
+ if not isinstance(media_list, Playlist | UserPlaylist | Mix):
+ logger_gui.error("Please select a playlist or mix.")
+ return
+
+ logger_gui.info(f"Fetching all tracks from: {media_list.name}")
+ media_items = items_results_all(self.tidal.session, media_list)
+
+ album_ids = self._extract_album_ids_from_tracks(media_items)
+
+ if not album_ids:
+ logger_gui.warning("No albums found in this playlist.")
+ return
+
+ logger_gui.info(f"Found {len(album_ids)} unique albums. Loading 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 playlist.")
+ return
+
+ self._queue_loaded_albums(albums_dict)
+
+ message = f"Added {len(albums_dict)} albums to download queue"
+ self.s_statusbar_message.emit(StatusbarMessage(message=message, timeout=3000))
+ logger_gui.info(message)
+
+ except Exception as e:
+ error_msg = f"Error downloading albums from playlist: {e!s}"
+ logger_gui.error(error_msg)
+ self.s_statusbar_message.emit(StatusbarMessage(message=error_msg, timeout=3000))
+
+ def _extract_album_ids_from_tracks(self, media_items: list) -> dict[int, Album]:
+ """Extract unique album IDs from a list of media items."""
+ album_ids = {}
+
+ for media_item in media_items:
+ if not isinstance(media_item, Track | Video):
+ continue
+
+ if not hasattr(media_item, "album") or not media_item.album:
+ continue
+
+ try:
+ album_id = media_item.album.id
+ if album_id:
+ album_ids[album_id] = media_item.album
+ except Exception as e:
+ logger_gui.debug(f"Skipping track with unavailable album: {e!s}")
+ continue
+
+ return album_ids
+
+ def _load_albums_with_rate_limiting(self, album_ids: dict[int, Album]) -> dict[int, Album]:
+ """Load full album objects with rate limiting to prevent API throttling."""
+ albums_dict = {}
+ batch_size = self.settings.data.api_rate_limit_batch_size
+ delay_sec = self.settings.data.api_rate_limit_delay_sec
+
+ for idx, album_id in enumerate(album_ids.keys(), start=1):
+ try:
+ if idx > 1 and (idx - 1) % batch_size == 0:
+ logger_gui.info(f"⏰ RATE LIMITING: Processed {idx - 1} albums, pausing for {delay_sec} seconds...")
+ time.sleep(delay_sec)
+
+ if not self.tidal.session.check_login():
+ logger_gui.error("Session expired. Please restart the application and login again.")
+ return albums_dict
+
+ album = self.tidal.session.album(album_id)
+ albums_dict[album.id] = album
+ logger_gui.debug(f"Loaded album {idx}/{len(album_ids)}: {name_builder_artist(album)} - {album.name}")
+
+ except Exception as e:
+ if not self._handle_album_load_error(e, album_id):
+ return albums_dict
+ continue
+
+ logger_gui.info(f"Successfully loaded {len(albums_dict)} albums.")
+ return albums_dict
+
+ def _handle_album_load_error(self, error: Exception, album_id: int) -> bool:
+ """Handle errors that occur when loading an album."""
+ if self.tidal.is_authentication_error(error):
+ error_msg = str(error)
+ logger_gui.error(f"Authentication error: {error_msg}")
+ logger_gui.error("Your session has expired. Please restart the application and login again.")
+ self.s_statusbar_message.emit(
+ StatusbarMessage(message="Session expired - please restart and login", timeout=5000)
+ )
+ return False
+
+ logger_gui.warning(f"Failed to load album {album_id}: {error!s}")
+ logger_gui.info(
+ "Note: Some albums may be unavailable due to region restrictions or removal from TIDAL. This is normal."
+ )
+ return True
+
+ def _queue_loaded_albums(self, albums_dict: dict[int, Album]) -> None:
+ """Prepare and add loaded albums to the download queue."""
+ logger_gui.info(f"Preparing queue items for {len(albums_dict)} albums...")
+
+ queue_items = []
+ for album in albums_dict.values():
+ queue_dl_item = self.queue_manager.media_to_queue_download_model(album)
+ if queue_dl_item:
+ queue_items.append((queue_dl_item, album))
+ logger_gui.debug(f"Prepared: {name_builder_artist(album)} - {album.name}")
+
+ logger_gui.info(f"Adding {len(queue_items)} albums to queue...")
+ for queue_dl_item, album in queue_items:
+ self.queue_manager.queue_download_media(queue_dl_item)
+ logger_gui.info(f"Added: {name_builder_artist(album)} - {album.name}")
+
+ def on_search_in_app(self, search_term: str, search_type: str):
+ """Perform a search within the application, selecting the correct category."""
+ self.l_search.setText(search_term)
+
+ search_type_map = {
+ "artist": Artist,
+ "album": Album,
+ "track": Track,
+ "video": Video,
+ "playlist": Playlist,
+ }
+ search_category = search_type_map.get(search_type.lower(), self.cb_search_type.currentData())
+
+ for i in range(self.cb_search_type.count()):
+ if self.cb_search_type.itemData(i) == search_category:
+ self.cb_search_type.setCurrentIndex(i)
+ break
+
+ self.search_manager.search_populate_results(search_term, search_category)
+
+ def on_search_in_browser(self, search_term: str, search_type: str):
+ """Open a search in the default web browser."""
+ safe_term = urllib.parse.quote(search_term)
+ search_path_map = {
+ "artist": "artists",
+ "album": "albums",
+ "track": "tracks",
+ "video": "videos",
+ "playlist": "playlists",
+ }
+ search_path = search_path_map.get(search_type.lower())
+
+ if search_path:
+ url = QtCore.QUrl(f"https://listen.tidal.com/search/{search_path}?q={safe_term}")
+ else:
+ url = QtCore.QUrl(f"https://listen.tidal.com/search?q={safe_term}")
+
+ QtGui.QDesktopServices.openUrl(url)