diff options
| author | Warry <FunWarry@users.noreply.github.com> | 2025-12-03 11:41:18 +0100 |
|---|---|---|
| committer | Warry <FunWarry@users.noreply.github.com> | 2025-12-03 11:41:18 +0100 |
| commit | c1d40619aa2aa44f5d967af9b5f47d040d6dc7dd (patch) | |
| tree | ad05969f0bae4cfe68e3e5baf9ea6a8954fa626a /tidal_dl_ng | |
| parent | 85f7e33b28985afd62a99d64b3e7a6883d62b072 (diff) | |
| parent | e89be804fd8fdbee1638f0cdc055eb095206ef8c (diff) | |
| download | tidal-dl-ng-c1d40619aa2aa44f5d967af9b5f47d040d6dc7dd.tar.gz | |
Merge branch 'feature/json-downloaded' into fusion-refac-&-info
# Conflicts:
# .gitignore
# README.md
# tidal_dl_ng/gui.py
Diffstat (limited to 'tidal_dl_ng')
| -rw-r--r-- | tidal_dl_ng/dialog_history.py | 327 | ||||
| -rw-r--r-- | tidal_dl_ng/download.py | 48 | ||||
| -rw-r--r-- | tidal_dl_ng/gui.py | 642 | ||||
| -rw-r--r-- | tidal_dl_ng/gui_playlist.py | 571 | ||||
| -rw-r--r-- | tidal_dl_ng/gui_queue.py | 29 | ||||
| -rw-r--r-- | tidal_dl_ng/gui_search.py | 285 | ||||
| -rw-r--r-- | tidal_dl_ng/history.py | 437 | ||||
| -rw-r--r-- | tidal_dl_ng/logger.py | 7 | ||||
| -rw-r--r-- | tidal_dl_ng/ui/dialog_history.py | 171 | ||||
| -rw-r--r-- | tidal_dl_ng/ui/dialog_history.ui | 206 | ||||
| -rw-r--r-- | tidal_dl_ng/ui/dialog_settings.py | 167 | ||||
| -rw-r--r-- | tidal_dl_ng/ui/dialog_settings.ui | 173 |
12 files changed, 2851 insertions, 212 deletions
diff --git a/tidal_dl_ng/dialog_history.py b/tidal_dl_ng/dialog_history.py new file mode 100644 index 0000000..2e447df --- /dev/null +++ b/tidal_dl_ng/dialog_history.py @@ -0,0 +1,327 @@ +""" +dialog_history.py + +Dialog for viewing and managing download history. +""" + +import os +import subprocess +import sys +from datetime import datetime +from pathlib import Path + +from PySide6 import QtCore, QtWidgets + +from tidal_dl_ng.history import HistoryService +from tidal_dl_ng.logger import logger_gui + + +class DialogHistory(QtWidgets.QDialog): + """Dialog for managing download history. + + Displays tracks grouped by source (album, playlist, mix) with ability to: + - View download dates and source information + - Export/Import history + - Clear history or remove selected items + - View statistics + """ + + def __init__(self, history_service: HistoryService, parent=None): + """Initialize the history dialog. + + Args: + history_service: The HistoryService instance. + parent: Parent widget. + """ + super().__init__(parent) + + # Import the generated UI + from tidal_dl_ng.ui.dialog_history import Ui_DialogHistory + + self.ui = Ui_DialogHistory() + self.ui.setupUi(self) + self.history_service = history_service + + self._init_ui() + self._connect_signals() + self._load_history() + + self.exec() + + def _init_ui(self): + """Initialize UI elements.""" + # Set file path + file_path = self.history_service.get_history_file_path() + self.ui.le_file_path.setText(file_path) + + # Configure tree widget + self.ui.tw_history.setColumnWidth(0, 400) + self.ui.tw_history.setColumnWidth(1, 100) + self.ui.tw_history.setColumnWidth(2, 180) + self.ui.tw_history.setColumnWidth(3, 100) + + # Set window size + self.resize(900, 600) + + def _connect_signals(self): + """Connect UI signals to handlers.""" + self.ui.pb_refresh.clicked.connect(self._load_history) + self.ui.pb_export.clicked.connect(self._on_export) + self.ui.pb_import.clicked.connect(self._on_import) + self.ui.pb_clear_history.clicked.connect(self._on_clear_history) + self.ui.pb_remove_selected.clicked.connect(self._on_remove_selected) + self.ui.pb_close.clicked.connect(self.close) + self.ui.pb_open_folder.clicked.connect(self._on_open_folder) + + def _load_history(self): + """Load and display the download history.""" + # Clear existing items + self.ui.tw_history.clear() + + # Get history grouped by source + grouped_history = self.history_service.get_history_by_source() + + # Get statistics + stats = self.history_service.get_statistics() + self._update_statistics(stats) + + # Sort sources by name + sorted_sources = sorted(grouped_history.items(), key=lambda x: x[0]) + + # Populate tree + for _, tracks in sorted_sources: + if not tracks: + continue + + # Create parent item for source + source_item = self._create_source_item(tracks) + self.ui.tw_history.addTopLevelItem(source_item) + + # Add tracks as children + for track_data in sorted(tracks, key=lambda x: x.get("download_date", ""), reverse=True): + track_item = self._create_track_item(track_data) + source_item.addChild(track_item) + + # Expand all top-level items + self.ui.tw_history.expandAll() + + logger_gui.info(f"Loaded {stats['total_tracks']} tracks from history") + + def _create_source_item(self, tracks: list) -> QtWidgets.QTreeWidgetItem: + """Create a tree widget item for a source. + + Args: + tracks: List of track data dictionaries. + + Returns: + QTreeWidgetItem for the source. + """ + if not tracks: + return QtWidgets.QTreeWidgetItem() + + first_track = tracks[0] + source_type = first_track.get("source_type", "unknown") + source_name = first_track.get("source_name", "Unknown") + source_id = first_track.get("source_id", "") + + # Format source name + if source_type == "manual" or not source_name: + display_name = f"📝 Manual Downloads ({len(tracks)} tracks)" + elif source_type == "album": + display_name = f"💿 {source_name} ({len(tracks)} tracks)" + elif source_type == "playlist": + display_name = f"📋 {source_name} ({len(tracks)} tracks)" + elif source_type == "mix": + display_name = f"🎵 {source_name} ({len(tracks)} tracks)" + elif source_type == "track": + display_name = f"🎼 Individual Tracks ({len(tracks)} tracks)" + else: + display_name = f"{source_name} ({len(tracks)} tracks)" + + item = QtWidgets.QTreeWidgetItem() + item.setText(0, display_name) + item.setText(1, source_type.capitalize()) + item.setText(2, "") + item.setText(3, source_id or "") + + # Make it bold + font = item.font(0) + font.setBold(True) + item.setFont(0, font) + + return item + + def _create_track_item(self, track_data: dict) -> QtWidgets.QTreeWidgetItem: + """Create a tree widget item for a track. + + Args: + track_data: Dictionary with track information. + + Returns: + QTreeWidgetItem for the track. + """ + track_id = track_data.get("track_id", "") + download_date = track_data.get("download_date", "") + + # Format date + try: + if download_date: + dt = datetime.fromisoformat(download_date.replace("Z", "+00:00")) + formatted_date = dt.strftime("%Y-%m-%d %H:%M:%S") + else: + formatted_date = "Unknown" + except: + formatted_date = download_date + + item = QtWidgets.QTreeWidgetItem() + item.setText(0, f" Track {track_id}") + item.setText(1, "Track") + item.setText(2, formatted_date) + item.setText(3, track_id) + + # Store track ID for later use + item.setData(0, QtCore.Qt.ItemDataRole.UserRole, track_id) + + return item + + def _update_statistics(self, stats: dict): + """Update statistics labels. + + Args: + stats: Dictionary with statistics. + """ + total = stats.get("total_tracks", 0) + by_type = stats.get("by_source_type", {}) + + self.ui.l_total_tracks.setText(f"Total Tracks: {total}") + self.ui.l_by_albums.setText(f"Albums: {by_type.get('album', 0)}") + self.ui.l_by_playlists.setText(f"Playlists: {by_type.get('playlist', 0)}") + self.ui.l_by_mixes.setText(f"Mixes: {by_type.get('mix', 0)}") + self.ui.l_by_manual.setText(f"Manual: {by_type.get('manual', 0)}") + + def _on_export(self): + """Handle export button click.""" + file_path, _ = QtWidgets.QFileDialog.getSaveFileName( + self, "Export Download History", "download_history_export.json", "JSON Files (*.json)" + ) + + if file_path: + success, message = self.history_service.export_history(file_path) + + if success: + QtWidgets.QMessageBox.information(self, "Export Successful", message) + logger_gui.info(f"Exported history to: {file_path}") + else: + QtWidgets.QMessageBox.critical(self, "Export Failed", f"Failed to export history:\n{message}") + logger_gui.error(f"Export failed: {message}") + + def _on_import(self): + """Handle import button click.""" + file_path, _ = QtWidgets.QFileDialog.getOpenFileName(self, "Import Download History", "", "JSON Files (*.json)") + + if not file_path: + return + + # Ask merge or replace + reply = QtWidgets.QMessageBox.question( + self, + "Import Mode", + "Do you want to MERGE with existing history?\n\n" + "Yes = Merge (add new tracks, keep existing)\n" + "No = Replace (delete all existing, import only new)\n" + "Cancel = Abort import", + QtWidgets.QMessageBox.StandardButton.Yes + | QtWidgets.QMessageBox.StandardButton.No + | QtWidgets.QMessageBox.StandardButton.Cancel, + ) + + if reply == QtWidgets.QMessageBox.StandardButton.Cancel: + return + + merge = reply == QtWidgets.QMessageBox.StandardButton.Yes + + # Perform import + success, message = self.history_service.import_history(file_path, merge=merge) + + if success: + QtWidgets.QMessageBox.information(self, "Import Successful", message) + logger_gui.info(f"Imported history from: {file_path}") + self._load_history() # Refresh display + else: + QtWidgets.QMessageBox.critical(self, "Import Failed", f"Failed to import history:\n{message}") + logger_gui.error(f"Import failed: {message}") + + def _on_clear_history(self): + """Handle clear history button click.""" + reply = QtWidgets.QMessageBox.warning( + self, + "Clear Download History", + "Are you sure you want to clear ALL download history?\n\n" "This action cannot be undone!", + QtWidgets.QMessageBox.StandardButton.Yes | QtWidgets.QMessageBox.StandardButton.No, + ) + + if reply == QtWidgets.QMessageBox.StandardButton.Yes: + self.history_service.clear_history() + QtWidgets.QMessageBox.information(self, "History Cleared", "Download history has been cleared.") + logger_gui.info("Download history cleared") + self._load_history() # Refresh display + + def _on_remove_selected(self): + """Handle remove selected button click.""" + selected_items = self.ui.tw_history.selectedItems() + + if not selected_items: + QtWidgets.QMessageBox.warning(self, "No Selection", "Please select one or more tracks to remove.") + return + + # Collect track IDs from selected items (only child items, not parents) + track_ids = [] + for item in selected_items: + # Check if it's a track (child item) by checking if it has a parent + if item.parent() is not None: + track_id = item.data(0, QtCore.Qt.ItemDataRole.UserRole) + if track_id: + track_ids.append(track_id) + + if not track_ids: + QtWidgets.QMessageBox.warning( + self, "No Tracks Selected", "Please select individual tracks (not source groups) to remove." + ) + return + + reply = QtWidgets.QMessageBox.question( + self, + "Remove Tracks", + f"Are you sure you want to remove {len(track_ids)} track(s) from history?", + QtWidgets.QMessageBox.StandardButton.Yes | QtWidgets.QMessageBox.StandardButton.No, + ) + + if reply == QtWidgets.QMessageBox.StandardButton.Yes: + removed_count = 0 + for track_id in track_ids: + if self.history_service.remove_track_from_history(track_id): + removed_count += 1 + + QtWidgets.QMessageBox.information(self, "Tracks Removed", f"Removed {removed_count} track(s) from history.") + logger_gui.info(f"Removed {removed_count} tracks from history") + self._load_history() # Refresh display + + def _on_open_folder(self): + """Open the folder containing the history file.""" + file_path = Path(self.history_service.get_history_file_path()) + folder_path = file_path.parent + + try: + if sys.platform == "win32": + os.startfile(folder_path) # noqa: S606 + elif sys.platform == "darwin": + # Security: folder_path is from config, not user input + subprocess.run(["open", str(folder_path)], check=False) # noqa: S603, S607 + else: + # Security: folder_path is from config, not user input + subprocess.run(["xdg-open", str(folder_path)], check=False) # noqa: S603, S607 + + logger_gui.info(f"Opened folder: {folder_path}") + except Exception as e: + QtWidgets.QMessageBox.warning(self, "Cannot Open Folder", f"Failed to open folder:\n{e!s}") + logger_gui.error(f"Failed to open folder: {e}") diff --git a/tidal_dl_ng/download.py b/tidal_dl_ng/download.py index a121f95..765c78b 100644 --- a/tidal_dl_ng/download.py +++ b/tidal_dl_ng/download.py @@ -75,6 +75,7 @@ from tidal_dl_ng.helper.tidal import ( name_builder_title, parse_track_and_album_extras, ) +from tidal_dl_ng.history import HistoryService from tidal_dl_ng.metadata import Metadata from tidal_dl_ng.model.downloader import DownloadSegmentResult, TrackStreamInfo from tidal_dl_ng.model.gui_data import ProgressBars @@ -121,6 +122,7 @@ class Download: progress_overall: Progress event_abort: Event event_run: Event + history_service: HistoryService def __init__( self, @@ -161,6 +163,7 @@ class Download: self.path_base = path_base self.event_abort = event_abort self.event_run = event_run + self.history_service = HistoryService() if not self.settings.data.path_binary_ffmpeg and ( self.settings.data.video_convert_mp4 or self.settings.data.extract_flac @@ -177,7 +180,7 @@ class Download: def _get_media_urls( self, media: Track | Video, - stream_manifest: StreamManifest | None = None, + stream_manifest: StreamManifest | None, ) -> list[str]: """Extract URLs for the given media item. @@ -531,6 +534,9 @@ class Download: is_parent_album: bool = False, list_position: int = 0, list_total: int = 0, + source_type: str = "manual", + source_id: str | None = None, + source_name: str | None = None, ) -> tuple[bool, pathlib.Path | str]: """Download a single media item, handling file naming, skipping, and post-processing. @@ -546,6 +552,9 @@ class Download: is_parent_album (bool, optional): Whether this is a parent album. Defaults to False. list_position (int, optional): Position in list. Defaults to 0. list_total (int, optional): Total items in list. Defaults to 0. + source_type (str, optional): Source type (playlist, album, manual, mix, track). Defaults to "manual". + source_id (str | None, optional): Source ID (UUID for playlist/album). Defaults to None. + source_name (str | None, optional): Source name for display. Defaults to None. Returns: tuple[bool, pathlib.Path | str]: (Downloaded, path to file) @@ -562,6 +571,13 @@ class Download: media, file_template, quality_audio, list_position, list_total ) + # Step 2b: Duplicate prevention based on persistent history + if isinstance(media, Track): + track_id = str(media.id) + if self.history_service.should_skip_download(track_id): + self.fn_logger.info(f"Skipped item '{name_builder_item(media)}' (already in history).") + return False, path_media_dst + if skip_file: self.fn_logger.debug(f"Download skipped, since file exists: '{path_media_dst}'") @@ -587,6 +603,16 @@ class Download: skip_file, ) + # Step 6: Add to download history if successful (only for Tracks) + if download_success and isinstance(media, Track): + try: + self.history_service.add_track_to_history( + track_id=str(media.id), source_type=source_type, source_id=source_id, source_name=source_name + ) + except Exception as e: + # Don't fail the download if history tracking fails + self.fn_logger.warning(f"Failed to add track to history: {e}") + return download_success, path_media_dst def _validate_and_prepare_media( @@ -1435,6 +1461,9 @@ class Download: download_delay: bool = True, quality_audio: Quality | None = None, quality_video: QualityVideo | None = None, + source_type: str = "manual", + source_id: str | None = None, + source_name: str | None = None, ) -> None: """Download all items in an album, playlist, or mix. @@ -1447,6 +1476,9 @@ class Download: download_delay (bool, optional): Whether to delay between downloads. Defaults to True. quality_audio (Quality | None, optional): Audio quality. Defaults to None. quality_video (QualityVideo | None, optional): Video quality. Defaults to None. + source_type (str, optional): Source type (playlist, album, manual, mix). Defaults to "manual". + source_id (str | None, optional): Source ID (UUID for playlist/album). Defaults to None. + source_name (str | None, optional): Source name for display. Defaults to None. """ # Validate and prepare media collection validated_media = self._validate_and_prepare_media(media, media_id, media_type, video_download) @@ -1482,6 +1514,9 @@ class Download: progress, progress_task, progress_stdout, + source_type, + source_id, + source_name, ) # Create playlist file if requested @@ -1520,7 +1555,7 @@ class Download: list_media_name_short: str = list_media_name[:30] # Get all items of the list. - items = items_results_all(media, videos_include=video_download) + items = items_results_all(self.tidal.session, media, videos_include=video_download) # Determine where to redirect the progress information. if self.progress_gui is None: @@ -1544,6 +1579,9 @@ class Download: progress: Progress, progress_task: TaskID, progress_stdout: bool, + source_type: str = "manual", + source_id: str | None = None, + source_name: str | None = None, ) -> list[pathlib.Path]: """Execute downloads for all items in the collection. @@ -1558,6 +1596,9 @@ class Download: progress (Progress): Progress bar instance. progress_task (TaskID): Progress task ID. progress_stdout (bool): Whether to show progress in stdout. + source_type (str, optional): Source type. Defaults to "manual". + source_id (str | None, optional): Source ID. Defaults to None. + source_name (str | None, optional): Source name. Defaults to None. Returns: list[pathlib.Path]: List of result directories. @@ -1589,6 +1630,9 @@ class Download: is_parent_album=is_album, list_position=count + 1, list_total=list_total, + source_type=source_type, + source_id=source_id, + source_name=source_name, ) for count, item_media in enumerate(items) ] diff --git a/tidal_dl_ng/gui.py b/tidal_dl_ng/gui.py index 4a22035..f6c2774 100644 --- a/tidal_dl_ng/gui.py +++ b/tidal_dl_ng/gui.py @@ -57,13 +57,20 @@ from tidalapi.session import LinkLogin from tidal_dl_ng import __version__, update_available from tidal_dl_ng.dialog import DialogLogin, DialogPreferences, DialogVersion +from tidal_dl_ng.dialog_history import DialogHistory from tidal_dl_ng.helper.gui import ( FilterHeader, HumanProxyModel, + get_queue_download_media, + get_queue_download_quality_audio, + get_queue_download_quality_video, get_results_media_item, get_user_list_media_item, + set_queue_download_media, + set_user_list_media, ) from tidal_dl_ng.helper.hover_manager import HoverManager +from tidal_dl_ng.helper.path import get_format_template, resource_path from tidal_dl_ng.helper.tidal import ( extract_contributor_names, favorite_function_factory, @@ -71,6 +78,7 @@ from tidal_dl_ng.helper.tidal import ( items_results_all, name_builder_artist, parse_track_and_album_extras, + user_media_lists, ) try: @@ -81,22 +89,25 @@ except ImportError as e: print("Qt dependencies missing. Cannot start GUI. Please read the 'README.md' carefully.") sys.exit(1) + from ansi2html import Ansi2HTMLConverter from rich.progress import Progress -from tidalapi import Album, Mix, Playlist, Quality, Track, Video +from tidalapi import Album, Mix, Playlist, Quality, Track, UserPlaylist, Video from tidalapi.artist import Artist +from tidalapi.playlist import Folder from tidalapi.session import SearchTypes from tidal_dl_ng.cache import TrackExtrasCache from tidal_dl_ng.config import HandlingApp, Settings, Tidal -from tidal_dl_ng.constants import QualityVideo +from tidal_dl_ng.constants import FAVORITES, QualityVideo, QueueDownloadStatus, TidalLists from tidal_dl_ng.download import Download from tidal_dl_ng.gui_covers import CoverManager from tidal_dl_ng.gui_playlist import GuiPlaylistManager from tidal_dl_ng.gui_queue import GuiQueueManager from tidal_dl_ng.gui_search import GuiSearchManager +from tidal_dl_ng.history import HistoryService from tidal_dl_ng.logger import XStream, logger_gui -from tidal_dl_ng.model.gui_data import ProgressBars, ResultItem, StatusbarMessage +from tidal_dl_ng.model.gui_data import ProgressBars, QueueDownloadItem, ResultItem, StatusbarMessage from tidal_dl_ng.model.meta import ReleaseLatest from tidal_dl_ng.ui.info_tab_widget import InfoTabWidget from tidal_dl_ng.ui.main import Ui_MainWindow @@ -114,6 +125,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): settings: Settings tidal: Tidal dl: Download + history_service: HistoryService threadpool: QtCore.QThreadPool tray: QtWidgets.QSystemTrayIcon spinners: dict @@ -174,6 +186,9 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): XStream.stdout().messageWritten.connect(self._log_output) # XStream.stderr().messageWritten.connect(self._log_output) + self.settings = Settings() + self.history_service = HistoryService() + # Core components self._init_threads() self._init_gui() @@ -190,11 +205,15 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self._init_tree_results(self.tr_results, self.model_tr_results) self.playlist_manager.init_ui() self.queue_manager.init_ui() + self._init_tree_lists(self.tr_lists_user) + self._init_tree_queue(self.tr_queue_download) + self._init_info() self._init_progressbar() self._populate_quality(self.cb_quality_audio, Quality) self._populate_quality(self.cb_quality_video, QualityVideo) self._populate_search_types(self.cb_search_type, SearchTypes) self.apply_settings(self.settings) + self._init_menu_actions() self._init_signals() # Connect signal for invoking track extras callbacks @@ -205,13 +224,24 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): logger_gui.debug("All setup.") def closeEvent(self, event: QtGui.QCloseEvent) -> None: - """Ensure background workers and hover hooks stop before exit.""" - HandlingApp().event_abort.set() - if hasattr(self, "hover_manager") and self.hover_manager: - with contextlib.suppress(Exception): - self.hover_manager.stop() - self.threadpool.waitForDone(2000) - super().closeEvent(event) + """Handle the close event of the main window. + + Args: + event (QtGui.QCloseEvent): The close event. + """ + # Save the main window size and position + self.settings.data.window_x = self.x() + self.settings.data.window_y = self.y() + self.settings.data.window_w = self.width() + self.settings.data.window_h = self.height() + self.settings.save() + + self.shutdown = True + + handling_app: HandlingApp = HandlingApp() + handling_app.event_abort.set() + + event.accept() def _init_gui(self) -> None: """Initialize GUI-specific variables and state.""" @@ -308,6 +338,12 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): # self.pb_progress.setVisible() self.statusbar.addPermanentWidget(pb) + def _init_info(self): + """Set default album cover image in the GUI.""" + path_image: str = resource_path("tidal_dl_ng/ui/default_album_image.png") + + self.l_pm_cover.setPixmap(QtGui.QPixmap(path_image)) + def get_track_extras(self, track_id: str, callback: Callable[[str, dict | None], None]) -> dict | None: """Return cached extras for a track or start async fetch. @@ -472,6 +508,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): tree.setColumnWidth(5, skinny_width) # duration tree.setColumnWidth(6, narrow_width) # quality tree.setColumnWidth(7, narrow_width) # date + tree.setColumnWidth(8, skinny_width) # downloaded? header.setSectionResizeMode(0, QtWidgets.QHeaderView.ResizeToContents) # Connect the contextmenu tree.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) @@ -495,12 +532,166 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): Args: model (QStandardItemModel): The model to initialize. """ - labels_column: list[str] = ["#", "obj", "Artist", "Title", "Album", "Duration", "Quality", "Date"] + labels_column: list[str] = [ + "#", + "obj", + "Artist", + "Title", + "Album", + "Duration", + "Quality", + "Date", + "Downloaded?", + ] model.setColumnCount(len(labels_column)) model.setRowCount(0) model.setHorizontalHeaderLabels(labels_column) + def _init_tree_queue(self, tree: QtWidgets.QTableWidget) -> None: + """Initialize the download queue table widget. + + Args: + tree (QTableWidget): The table widget. + """ + tree.setColumnHidden(1, True) + tree.setColumnWidth(2, 200) + + header = tree.header() + + if hasattr(header, "setSectionResizeMode"): + header.setSectionResizeMode(0, QtWidgets.QHeaderView.ResizeToContents) + tree.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + tree.customContextMenuRequested.connect(self.menu_context_queue_download) + + def tidal_user_lists(self) -> None: + """Fetch and emit user playlists, mixes, and favorites from Tidal.""" + # Start loading spinner + self.s_spinner_start.emit(self.tr_lists_user) + self.s_pb_reload_status.emit(False) + + 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: dict[str, list]) -> None: + """Populate the user lists tree with playlists, mixes, and favorites. + + Args: + 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 + )[0] + twi_mixes: QtWidgets.QTreeWidgetItem = self.tr_lists_user.findItems( + TidalLists.Mixes, QtCore.Qt.MatchExactly, 0 + )[0] + twi_favorites: QtWidgets.QTreeWidgetItem = self.tr_lists_user.findItems( + TidalLists.Favorites, QtCore.Qt.MatchExactly, 0 + )[0] + + # Remove all children if present + for twi in [twi_playlists, twi_mixes]: + for i in reversed(range(twi.childCount())): + twi.removeChild(twi.child(i)) + + # 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}" + 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 + 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())): + twi_favorites.removeChild(twi_favorites.child(i)) + + # Populate static favorites + for key, favorite in FAVORITES.items(): + twi_child = QtWidgets.QTreeWidgetItem(twi_favorites) + name: str = favorite["name"] + info: str = "" + + twi_child.setText(0, name) + set_user_list_media(twi_child, key) + twi_child.setText(2, info) + + # Stop load spinner + self.s_spinner_stop.emit() + self.s_pb_reload_status.emit(True) + + def _init_tree_lists(self, tree: QtWidgets.QTreeWidget) -> None: + """Initialize the user lists tree widget. + + Args: + tree (QTreeWidget): The tree widget. + """ + # Adjust Tree. + tree.setColumnWidth(0, 200) + tree.setColumnHidden(1, True) + tree.setColumnWidth(2, 300) + tree.expandAll() + + # Connect the contextmenu + tree.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + tree.customContextMenuRequested.connect(self.playlist_manager.menu_context_tree_lists) + + def _init_menu_actions(self) -> None: + """Initialize custom menu actions.""" + # Create Tools menu if it doesn't exist + menubar = self.menuBar() + tools_menu = None + + # Find or create Tools menu + for action in menubar.actions(): + if action.text() == "Tools": + tools_menu = action.menu() + break + + if not tools_menu: + tools_menu = menubar.addMenu("Tools") + + # Create View History action + self.a_view_history = QtGui.QAction("View Download History...", self) + self.a_view_history.triggered.connect(self.on_view_history) + tools_menu.addAction(self.a_view_history) + + # Add separator + tools_menu.addSeparator() + + # Add duplicate prevention toggle + self.a_toggle_duplicate_prevention = QtGui.QAction("Prevent Duplicate Downloads", self) + self.a_toggle_duplicate_prevention.setCheckable(True) + is_preventing = self.history_service.get_settings().get("preventDuplicates", True) + self.a_toggle_duplicate_prevention.setChecked(is_preventing) + self.a_toggle_duplicate_prevention.triggered.connect(self.on_toggle_duplicate_prevention) + tools_menu.addAction(self.a_toggle_duplicate_prevention) + def on_update_check(self, on_startup: bool = True) -> None: """Check for application updates and emit update signals. @@ -587,10 +778,185 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): 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)) + # Add mark/unmark as downloaded for Tracks + 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. + + Args: + point (QPoint): The point where the menu is requested. + """ + # Get the item at this point + item = self.tr_queue_download.itemAt(point) + + if not item: + return + + # Build the menu + menu = QtWidgets.QMenu() + + # Show remove option for waiting items + 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. + + Args: + item (QTreeWidgetItem): The item to remove. + """ + 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_mark_track_as_downloaded(self, track: Track, index: QtCore.QModelIndex) -> None: + """Mark a track as downloaded in history. + + Args: + track (Track): The track to mark. + index (QModelIndex): The model index of the track. + """ + track_id = str(track.id) + + # Determine source information (manual for now) + source_type = "manual" + source_id = None + source_name = None + + # Try to get album information if available + if hasattr(track, "album") and track.album: + source_type = "album" + source_id = str(track.album.id) + source_name = track.album.name + + self.history_service.add_track_to_history( + track_id=track_id, source_type=source_type, source_id=source_id, source_name=source_name + ) + + # Update the UI - refresh the downloaded column + self._update_downloaded_column(index, True) + logger_gui.info(f"Marked track as downloaded: {track.name}") + + def on_mark_track_as_not_downloaded(self, track_id: str, index: QtCore.QModelIndex) -> None: + """Remove a track from download history. + + Args: + track_id (str): The track ID to remove. + index (QModelIndex): The model index of the track. + """ + success = self.history_service.remove_track_from_history(track_id) + + if success: + # Update the UI - refresh the downloaded column + self._update_downloaded_column(index, False) + logger_gui.info(f"Unmarked track (ID: {track_id})") + + def _update_downloaded_column(self, index: QtCore.QModelIndex, is_downloaded: bool) -> None: + """Update the Downloaded? column for a specific index. + + Args: + index (QModelIndex): The model index of the item. + is_downloaded (bool): Whether the track is downloaded. + """ + # Get the source index (column 0) + source_index = self.proxy_tr_results.mapToSource(index) + + # Get the item from the model + item = self.model_tr_results.itemFromIndex(source_index) + if not item: + return + + # Get the parent row (the row contains all columns) + row = item.row() + parent = item.parent() + + # Column 8 is "Downloaded?" + downloaded_item = self.model_tr_results.item(row, 8) if parent is None else parent.child(row, 8) + + if downloaded_item: + if is_downloaded: + downloaded_item.setText("✅") + downloaded_item.setTextAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + else: + downloaded_item.setText("") + + def thread_download_list_media(self, point: QtCore.QPoint) -> None: + """Start download of a list media item in a thread. + + Args: + point (QPoint): The point in the tree. + """ + self.thread_it(self.playlist_manager.on_download_list_media, point) + + def on_download_all_albums_from_playlist(self, point: QtCore.QPoint) -> None: + """Download all unique albums from tracks in a playlist. + + Args: + point (QPoint): The point in the tree where the playlist was right-clicked. + """ + try: + # Get and validate the playlist + 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 + + # Get all items from the playlist + logger_gui.info(f"Fetching all tracks from: {media_list.name}") + media_items = items_results_all(self.tidal.session, media_list) + + # Extract unique album IDs from tracks + 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...") + + # Load albums 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 + + # Prepare and queue albums + self._queue_loaded_albums(albums_dict) + + # Show confirmation + 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. @@ -791,6 +1157,14 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): item.date_user_added if item.date_user_added != "" else item.date_release ) + # Check download history + child_downloaded: QtGui.QStandardItem = QtGui.QStandardItem() + if isinstance(item.obj, Track): + track_id = str(item.obj.id) + if self.history_service.is_downloaded(track_id): + child_downloaded.setText("✅") + child_downloaded.setTextAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + if isinstance(item.obj, Mix | Playlist | Album | Artist): # Add a disabled dummy child, so expansion arrow will appear. This Child will be replaced on expansion. child_dummy: QtGui.QStandardItem = QtGui.QStandardItem() @@ -807,6 +1181,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): child_duration, child_quality, child_date, + child_downloaded, ) def on_tr_results_add_top_level_item(self, item_child: Sequence[QtGui.QStandardItem]): @@ -1050,10 +1425,230 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): media: Track | Album | Playlist | Video | Artist = get_results_media_item( item, self.proxy_tr_results, self.model_tr_results ) - queue_dl_item = self.queue_manager.media_to_queue_download_model(media) + queue_dl_item: QueueDownloadItem = self.search_manager.media_to_queue_download_model(media) if queue_dl_item: - self.queue_manager.queue_download_media(queue_dl_item) + self.queue_download_media(queue_dl_item) + + def queue_download_media(self, queue_dl_item: QueueDownloadItem) -> None: + """Add a media item to the download queue. + + Args: + queue_dl_item (QueueDownloadItem): The item to add to the queue. + """ + # Populate child + child: QtWidgets.QTreeWidgetItem = QtWidgets.QTreeWidgetItem() + + child.setText(0, queue_dl_item.status) + set_queue_download_media(child, queue_dl_item.obj) + child.setText(2, queue_dl_item.name) + child.setText(3, queue_dl_item.type_media) + child.setText(4, queue_dl_item.quality_audio) + child.setText(5, queue_dl_item.quality_video) + self.tr_queue_download.addTopLevelItem(child) + + def watcher_queue_download(self) -> None: + """Monitor the download queue and process items as they become available.""" + handling_app: HandlingApp = HandlingApp() + + while not handling_app.event_abort.is_set(): + items: list[QtWidgets.QTreeWidgetItem | None] = self.tr_queue_download.findItems( + QueueDownloadStatus.Waiting, QtCore.Qt.MatchFlag.MatchExactly, column=0 + ) + + if len(items) > 0: + result: QueueDownloadStatus + item: QtWidgets.QTreeWidgetItem = items[0] + media: Track | Album | Playlist | Video | Mix | Artist = get_queue_download_media(item) + quality_audio: Quality = get_queue_download_quality_audio(item) + quality_video: QualityVideo = get_queue_download_quality_video(item) + + try: + self.s_queue_download_item_downloading.emit(item) + result = self.on_queue_download(media, quality_audio=quality_audio, quality_video=quality_video) + + if result == QueueDownloadStatus.Finished: + self.s_queue_download_item_finished.emit(item) + elif result == QueueDownloadStatus.Skipped: + self.s_queue_download_item_skipped.emit(item) + except Exception as e: + logger_gui.error(e) + self.s_queue_download_item_failed.emit(item) + else: + time.sleep(2) + + def on_queue_download_item_downloading(self, item: QtWidgets.QTreeWidgetItem) -> None: + """Update the status of a queue download item to 'Downloading'. + + Args: + item (QtWidgets.QTreeWidgetItem): The item to update. + """ + self.queue_download_item_status(item, QueueDownloadStatus.Downloading) + + def on_queue_download_item_finished(self, item: QtWidgets.QTreeWidgetItem) -> None: + """Update the status of a queue download item to 'Finished'. + + Args: + item (QtWidgets.QTreeWidgetItem): The item to update. + """ + self.queue_download_item_status(item, QueueDownloadStatus.Finished) + + def on_queue_download_item_failed(self, item: QtWidgets.QTreeWidgetItem) -> None: + """Update the status of a queue download item to 'Failed'. + + Args: + item (QtWidgets.QTreeWidgetItem): The item to update. + """ + self.queue_download_item_status(item, QueueDownloadStatus.Failed) + + def on_queue_download_item_skipped(self, item: QtWidgets.QTreeWidgetItem) -> None: + """Update the status of a queue download item to 'Skipped'. + + Args: + item (QtWidgets.QTreeWidgetItem): The item to update. + """ + self.queue_download_item_status(item, QueueDownloadStatus.Skipped) + + def queue_download_item_status(self, item: QtWidgets.QTreeWidgetItem, status: str) -> None: + """Set the status text of a queue download item. + + Args: + item (QtWidgets.QTreeWidgetItem): The item to update. + status (str): The status text. + """ + item.setText(0, status) + + def on_queue_download( + self, + media: Track | Album | Playlist | Video | Mix | Artist, + quality_audio: Quality | None = None, + quality_video: QualityVideo | None = None, + ) -> QueueDownloadStatus: + """Download the specified media item(s) and return the result status. + + Args: + media (Track | Album | Playlist | Video | Mix | Artist): The media item(s) to download. + quality_audio (Quality | None, optional): Desired audio quality. Defaults to None. + quality_video (QualityVideo | None, optional): Desired video quality. Defaults to None. + + Returns: + QueueDownloadStatus: The status of the download operation. + """ + result: QueueDownloadStatus + items_media: [Track | Album | Playlist | Video | Mix | Artist] + + if isinstance(media, Artist): + items_media: [Album] = items_results_all(self.tidal.session, media) + else: + items_media = [media] + + download_delay: bool = bool(isinstance(media, Track | Video) and self.settings.data.download_delay) + + for item_media in items_media: + result = self.download( + item_media, + self.dl, + delay_track=download_delay, + quality_audio=quality_audio, + quality_video=quality_video, + ) + + return result + + def download( + self, + media: Track | Album | Playlist | Video | Mix | Artist, + dl: Download, + delay_track: bool = False, + quality_audio: Quality | None = None, + quality_video: QualityVideo | None = None, + ) -> QueueDownloadStatus: + """Download a media item and return the result status. + + Args: + media (Track | Album | Playlist | Video | Mix | Artist): The media item to download. + dl (Download): The Download object to use. + delay_track (bool, optional): Whether to apply download delay. Defaults to False. + quality_audio (Quality | None, optional): Desired audio quality. Defaults to None. + quality_video (QualityVideo | None, optional): Desired video quality. Defaults to None. + + Returns: + QueueDownloadStatus: The status of the download operation. + """ + result_dl: bool + path_file: str + result: QueueDownloadStatus + self.s_pb_reset.emit() + self.s_statusbar_message.emit(StatusbarMessage(message="Download started...")) + + file_template = get_format_template(media, self.settings) + + # Determine source information + source_type = "manual" + source_id = None + source_name = None + + if isinstance(media, Album): + source_type = "album" + source_id = str(media.id) + source_name = media.name + elif isinstance(media, Playlist | UserPlaylist): + source_type = "playlist" + source_id = str(media.id) if hasattr(media, "id") else None + source_name = media.name if hasattr(media, "name") else None + elif isinstance(media, Mix): + source_type = "mix" + source_id = str(media.id) + source_name = media.title + elif isinstance(media, Track): + # For individual tracks, try to get album info + if hasattr(media, "album") and media.album: + source_type = "album" + source_id = str(media.album.id) + source_name = media.album.name + else: + source_type = "track" + source_id = str(media.id) + source_name = media.name + + if isinstance(media, Track | Video): + result_dl, path_file = dl.item( + media=media, + file_template=file_template, + download_delay=delay_track, + quality_audio=quality_audio, + quality_video=quality_video, + source_type=source_type, + source_id=source_id, + source_name=source_name, + ) + elif isinstance(media, Album | Playlist | Mix): + dl.items( + media=media, + file_template=file_template, + video_download=self.settings.data.video_download, + download_delay=self.settings.data.download_delay, + quality_audio=quality_audio, + quality_video=quality_video, + source_type=source_type, + source_id=source_id, + source_name=source_name, + ) + + # Dummy values + result_dl = True + path_file = "dummy" + + self.s_statusbar_message.emit(StatusbarMessage(message="Download finished.", timeout=2000)) + + if result_dl and path_file: + result = QueueDownloadStatus.Finished + elif not result_dl and path_file: + result = QueueDownloadStatus.Skipped + else: + result = QueueDownloadStatus.Failed + + return result def on_version( self, update_check: bool = False, update_available: bool = False, update_info: ReleaseLatest | None = None @@ -1071,6 +1666,21 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): """Open the preferences dialog.""" DialogPreferences(settings=self.settings, settings_save=self.s_settings_save, parent=self) + def on_view_history(self) -> None: + """Open the download history dialog.""" + DialogHistory(history_service=self.history_service, parent=self) + + def on_toggle_duplicate_prevention(self, enabled: bool) -> None: + """Toggle duplicate download prevention on or off. + + Args: + enabled (bool): Whether duplicate prevention is enabled. + """ + self.history_service.update_settings(preventDuplicates=enabled) + status_msg = "enabled" if enabled else "disabled" + logger_gui.info(f"Duplicate download prevention {status_msg}") + self.s_statusbar_message.emit(StatusbarMessage(message=f"Duplicate prevention {status_msg}.", timeout=2500)) + def on_tr_results_expanded(self, index: QtCore.QModelIndex) -> None: """Handle the event when a result item group is expanded. @@ -1165,11 +1775,13 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): full_album_object = self.tidal.session.album(media_track.album.id) # Convert the full album object into a queue item - queue_dl_item = self.queue_manager.media_to_queue_download_model(full_album_object) + queue_dl_item: QueueDownloadItem | None = self.search_manager.media_to_queue_download_model( + full_album_object + ) if queue_dl_item: # Add the item to the download queue - self.queue_manager.queue_download_media(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: diff --git a/tidal_dl_ng/gui_playlist.py b/tidal_dl_ng/gui_playlist.py index 9fe97a4..a787b99 100644 --- a/tidal_dl_ng/gui_playlist.py +++ b/tidal_dl_ng/gui_playlist.py @@ -1,9 +1,9 @@ # tidal_dl_ng/gui_playlist.py -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from PySide6 import QtCore, QtWidgets -from tidalapi import Album, Mix, Playlist, UserPlaylist +from tidalapi import Album, Artist, Mix, Playlist, Track, UserPlaylist from tidalapi.playlist import Folder from tidal_dl_ng.constants import FAVORITES, TidalLists @@ -14,7 +14,7 @@ from tidal_dl_ng.helper.tidal import ( user_media_lists, ) from tidal_dl_ng.logger import logger_gui -from tidal_dl_ng.model.gui_data import StatusbarMessage +from tidal_dl_ng.model.gui_data import QueueDownloadItem, ResultItem, StatusbarMessage if TYPE_CHECKING: from tidal_dl_ng.gui import MainWindow @@ -100,14 +100,27 @@ class GuiPlaylistManager: self.main_window.s_pb_reload_status.emit(True) def menu_context_tree_lists(self, point: QtCore.QPoint) -> None: - """Show context menu for user lists tree.""" + """Show context menu for user lists tree. + + Args: + point (QPoint): The point where the menu is requested. + """ + # Infos about the node selected. index = self.main_window.tr_lists_user.indexAt(point) + + # Do not open menu if something went wrong or a parent node is clicked. if not index.isValid() or not index.parent().data(): return + + # Get the media item to determine type item = self.main_window.tr_lists_user.itemAt(point) media = get_user_list_media_item(item) + + # We build the menu. menu = QtWidgets.QMenu() + if isinstance(media, Folder): + # Folder-specific menu items menu.addAction( "Download All Playlists in Folder", lambda: self.main_window.thread_it(self.on_download_folder_playlists, point), @@ -117,13 +130,15 @@ class GuiPlaylistManager: lambda: self.main_window.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.main_window.thread_it(self.on_download_favorites, point)) menu.addAction( "Download All Albums from Items", lambda: self.main_window.thread_it(self.on_download_albums_from_favorites, point), ) else: - menu.addAction("Download Playlist", lambda: self.main_window.thread_it(self.on_download_list_media, point)) + # Playlist/Mix menu items (existing) + menu.addAction("Download Playlist", lambda: self.main_window.thread_download_list_media(point)) menu.addAction( "Download All Albums in Playlist", lambda: self.main_window.thread_it(self.on_download_all_albums_from_playlist, point), @@ -131,89 +146,189 @@ class GuiPlaylistManager: menu.addAction( "Copy Share URL", lambda: self.main_window.on_copy_url_share(self.main_window.tr_lists_user, point) ) - menu.exec_(self.main_window.tr_lists_user.mapToGlobal(point)) + + menu.exec(self.main_window.tr_lists_user.mapToGlobal(point)) def on_download_list_media(self, point: QtCore.QPoint | None = None) -> None: - """Download all media items in a selected list.""" - items = ( - [self.main_window.tr_lists_user.itemAt(point)] if point else self.main_window.tr_lists_user.selectedItems() - ) - if not items: - logger_gui.error("Please select a mix or playlist first.") - return + """Download all media items in a selected list. + + Args: + point (QPoint | None, optional): The point in the tree. Defaults to None. + """ + items: list[QtWidgets.QTreeWidgetItem] = [] + + if point: + items = [self.main_window.tr_lists_user.itemAt(point)] + else: + items = self.main_window.tr_lists_user.selectedItems() + + if len(items) == 0: + logger_gui.error("Please select a mix or playlist first.") + for item in items: media = get_user_list_media_item(item) - queue_dl_item = self.main_window.queue_manager.media_to_queue_download_model(media) + queue_dl_item: QueueDownloadItem | None = self.main_window.search_manager.media_to_queue_download_model( + media + ) + if queue_dl_item: - self.main_window.queue_manager.queue_download_media(queue_dl_item) + self.main_window.queue_download_media(queue_dl_item) def on_download_folder_playlists(self, point: QtCore.QPoint) -> None: - """Download all playlists in a folder.""" - item = self.main_window.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 - 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"Queueing {len(playlists)} playlists from folder: {media.name}") - for playlist in playlists: - queue_dl_item = self.main_window.queue_manager.media_to_queue_download_model(playlist) - if queue_dl_item: - self.main_window.queue_manager.queue_download_media(queue_dl_item) - logger_gui.info(f"✅ Successfully queued {len(playlists)} playlists from folder: {media.name}") + """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.main_window.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.main_window.search_manager.media_to_queue_download_model( + playlist + ) + + if queue_dl_item: + self.main_window.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.""" - item = self.main_window.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 - playlists = self._get_folder_playlists(media) - if not playlists: - return - all_tracks = [] - for playlist in playlists: - try: - tracks = self._get_playlist_tracks(playlist) - all_tracks.extend(tracks) - except Exception as e: - logger_gui.error(f"Error getting tracks from playlist '{playlist.name}': {e}") - if not all_tracks: - return - album_ids = self.main_window._extract_album_ids_from_tracks(all_tracks) - if not album_ids: - return - albums_dict = self.main_window._load_albums_with_rate_limiting(album_ids) - if not albums_dict: - return - self.main_window._queue_loaded_albums(albums_dict) + """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.main_window.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.main_window._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.main_window._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.main_window._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.""" - item = self.main_window.tr_lists_user.itemAt(point) - media_key = get_user_list_media_item(item) - if not isinstance(media_key, str): - logger_gui.error("Please select a favorites category.") - return - favorite_name = FAVORITES.get(media_key, {}).get("name", media_key) - logger_gui.info(f"Fetching all items from favorites: {favorite_name}") - favorite_function = favorite_function_factory(self.main_window.tidal, media_key) - media_items = favorite_function() - if not media_items: - logger_gui.info(f"No items found in favorites: {favorite_name}") - return - queued_count = 0 - for media_item in media_items: - queue_dl_item = self.main_window.queue_manager.media_to_queue_download_model(media_item) - if queue_dl_item: - self.main_window.queue_manager.queue_download_media(queue_dl_item) - queued_count += 1 - logger_gui.info(f"✅ Successfully queued {queued_count} items from favorites: {favorite_name}") + """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.main_window.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.main_window.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.main_window.search_manager.media_to_queue_download_model( + media_item + ) + + if queue_dl_item: + self.main_window.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_fav_artists(self, media_items: list) -> None: """Download all albums from favorite artists.""" @@ -238,23 +353,47 @@ class GuiPlaylistManager: self.main_window._queue_loaded_albums(albums_dict) def on_download_albums_from_favorites(self, point: QtCore.QPoint) -> None: - """Download all unique albums from items in a Favorites category.""" - item = self.main_window.tr_lists_user.itemAt(point) - media_key = get_user_list_media_item(item) - if not isinstance(media_key, str): - return - favorite_function = favorite_function_factory(self.main_window.tidal, media_key) - media_items = favorite_function() - if not media_items: - return + """Download all unique albums from items in a Favorites category. - if media_key == "fav_albums": - albums_dict = {album.id: album for album in media_items if isinstance(album, Album) and album.id} - self.main_window._queue_loaded_albums(albums_dict) - elif media_key == "fav_artists": - self._download_albums_from_fav_artists(media_items) - else: - self._download_albums_from_fav_tracks(media_items) + Args: + point (QPoint): The point in the tree where the favorites item was right-clicked. + """ + try: + # Get and validate the favorites item + item = self.main_window.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.main_window.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 on_download_all_albums_from_playlist(self, point: QtCore.QPoint) -> None: """Download all unique albums from tracks in a playlist.""" @@ -277,38 +416,70 @@ class GuiPlaylistManager: self.main_window.s_statusbar_message.emit(StatusbarMessage(message=message, timeout=3000)) def on_tr_lists_user_expanded(self, item: QtWidgets.QTreeWidgetItem) -> None: - """Handle expansion of folders in the user lists tree.""" + """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.main_window.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.""" - folder = get_user_list_media_item(parent_item) + """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.main_window.s_spinner_start.emit(self.main_window.tr_lists_user) + try: + # Fetch folder contents folders, playlists = self._fetch_folder_contents(folder) + + # Emit signal to populate in main thread self.main_window.s_populate_folder_children.emit(parent_item, folders, playlists) + finally: self.main_window.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.""" + """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) - twi_child.setText(0, playlist.name or "") + 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: @@ -316,10 +487,21 @@ class GuiPlaylistManager: twi_child.setText(2, info) def _fetch_folder_contents(self, folder: Folder) -> tuple[list[Folder], list[Playlist]]: - """Fetch contents of a folder.""" - folder_id = folder.id or "root" - folders, playlists = [], [] - offset, limit = 0, 50 + """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.main_window.tidal.session.user.favorites.playlist_folders( limit=limit, offset=offset, parent_folder_id=folder_id @@ -330,7 +512,11 @@ class GuiPlaylistManager: 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: @@ -339,46 +525,173 @@ class GuiPlaylistManager: 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.""" + """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: - """Fetch all tracks from a playlist.""" - return [ - item - for item in items_results_all(self.main_window.tidal.session, playlist) - if isinstance(item, self.main_window.tidal.session.track) - ] + 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(self.main_window.tidal.session, 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.""" + """Show the items in the selected playlist or mix. + + Args: + item (QtWidgets.QTreeWidgetItem): The selected tree widget item. + """ self.main_window.thread_it(self.list_items_show, item) def list_items_show(self, item: QtWidgets.QTreeWidgetItem) -> None: - """Fetch and display the items in a playlist, mix, or folder.""" - media_list = get_user_list_media_item(item) - if not media_list: - return - self.main_window.s_spinner_start.emit(self.main_window.tr_results) - try: - if isinstance(media_list, Folder): - self._show_folder_contents(media_list) - elif isinstance(media_list, str) and media_list.startswith("fav_"): - function_list = favorite_function_factory(self.main_window.tidal, media_list) - self.main_window.list_items_show_result(favorite_function=function_list) - else: - self.main_window.list_items_show_result(media_list) - self.main_window.thread_it(self.main_window.cover_manager.load_cover, media_list) - finally: - self.main_window.s_spinner_stop.emit() + """Fetch and display the items in a playlist, mix, or folder. + + Args: + item (QtWidgets.QTreeWidgetItem): The tree widget item representing a playlist, mix, or folder. + """ + 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.main_window.s_spinner_start.emit(self.main_window.tr_results) + try: + 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.main_window.tidal, media_list) + self.main_window.list_items_show_result(favorite_function=function_list) + else: + self.main_window.list_items_show_result(media_list) + # Load cover asynchronously to avoid blocking the GUI + if hasattr(self.main_window, "cover_manager"): + self.main_window.cover_manager.load_cover(media_list) + finally: + self.main_window.s_spinner_stop.emit() def _show_folder_contents(self, folder: Folder) -> None: - """Display folder contents in results pane.""" + """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.main_window.search_manager.search_result_to_model(items) - self.main_window.search_manager.populate_tree_results(result) + self.main_window.populate_tree_results(result) + + 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.main_window._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(self.main_window.tidal.session, 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.main_window._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.main_window._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.main_window._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.main_window._queue_loaded_albums(albums_dict) + logger_gui.info(f"✅ Successfully queued {len(albums_dict)} unique albums from favorites: {favorite_name}") + + def search_populate_results(self, query: str, type_media: Any) -> None: + """Populate the results tree with search results. + + Args: + query (str): The search query. + type_media (SearchTypes): The type of media to search for. + """ + results: list[ResultItem] = self.main_window.search_manager.search(query, [type_media]) + + self.main_window.populate_tree_results(results) diff --git a/tidal_dl_ng/gui_queue.py b/tidal_dl_ng/gui_queue.py index 3abd3f0..d716a08 100644 --- a/tidal_dl_ng/gui_queue.py +++ b/tidal_dl_ng/gui_queue.py @@ -82,18 +82,23 @@ class GuiQueueManager: def on_queue_download_clear_all(self) -> None: """Clear all items from the download queue.""" self.on_clear_queue_download( - f"({QueueDownloadStatus.Waiting}|{QueueDownloadStatus.Finished}|{QueueDownloadStatus.Failed}|{QueueDownloadStatus.Skipped})" + f"({QueueDownloadStatus.Waiting}|{QueueDownloadStatus.Finished}|{QueueDownloadStatus.Failed})" ) def on_queue_download_clear_finished(self) -> None: """Clear finished items from the download queue.""" - self.on_clear_queue_download(f"({QueueDownloadStatus.Finished}|{QueueDownloadStatus.Skipped})") + self.on_clear_queue_download(f"[{QueueDownloadStatus.Finished}]") def on_clear_queue_download(self, regex: str) -> None: - """Clear items from the download queue matching the given regex.""" - items = self.main_window.tr_queue_download.findItems( + """Clear items from the download queue matching the given regex. + + Args: + regex (str): Regular expression to match items. + """ + items: list[QtWidgets.QTreeWidgetItem | None] = self.main_window.tr_queue_download.findItems( regex, QtCore.Qt.MatchFlag.MatchRegularExpression, column=0 ) + for item in items: self.main_window.tr_queue_download.takeTopLevelItem( self.main_window.tr_queue_download.indexOfTopLevelItem(item) @@ -101,12 +106,14 @@ class GuiQueueManager: def on_queue_download_remove(self) -> None: """Remove selected items from the download queue.""" - items = self.main_window.tr_queue_download.selectedItems() - if not items: + items: list[QtWidgets.QTreeWidgetItem | None] = self.main_window.tr_queue_download.selectedItems() + + if len(items) == 0: logger_gui.error("Please select an item from the queue first.") else: for item in items: status: str = item.text(0) + if status != QueueDownloadStatus.Downloading: self.main_window.tr_queue_download.takeTopLevelItem( self.main_window.tr_queue_download.indexOfTopLevelItem(item) @@ -115,8 +122,12 @@ class GuiQueueManager: logger_gui.info("Cannot remove a currently downloading item from queue.") def on_pb_queue_download_toggle(self) -> None: - """Toggle download status (pause / resume) accordingly.""" + """Toggle download status (pause / resume) accordingly. + + :return: None + """ handling_app: HandlingApp = HandlingApp() + if handling_app.event_run.is_set(): self.pb_queue_download_pause() else: @@ -125,7 +136,9 @@ class GuiQueueManager: def pb_queue_download_run(self) -> None: """Start the download queue and update the button state.""" handling_app: HandlingApp = HandlingApp() + handling_app.event_run.set() + icon = QtGui.QIcon(QtGui.QIcon.fromTheme(QtGui.QIcon.ThemeIcon.MediaPlaybackPause)) self.main_window.pb_queue_download_toggle.setIcon(icon) self.main_window.pb_queue_download_toggle.setStyleSheet("background-color: #e0a800; color: #212529") @@ -133,7 +146,9 @@ class GuiQueueManager: def pb_queue_download_pause(self) -> None: """Pause the download queue and update the button state.""" handling_app: HandlingApp = HandlingApp() + handling_app.event_run.clear() + icon = QtGui.QIcon(QtGui.QIcon.fromTheme(QtGui.QIcon.ThemeIcon.MediaPlaybackStart)) self.main_window.pb_queue_download_toggle.setIcon(icon) self.main_window.pb_queue_download_toggle.setStyleSheet("background-color: #218838; color: #fff") diff --git a/tidal_dl_ng/gui_search.py b/tidal_dl_ng/gui_search.py index fb303ad..e9a1db5 100644 --- a/tidal_dl_ng/gui_search.py +++ b/tidal_dl_ng/gui_search.py @@ -3,21 +3,24 @@ from typing import TYPE_CHECKING, Any from tidalapi import Album, Artist, Mix, Playlist, Track, Video -from tidalapi.media import AudioMode +from tidalapi import VideoQuality as QualityVideo +from tidalapi.media import AudioMode, Quality from tidalapi.playlist import Folder from tidalapi.session import SearchTypes +from tidal_dl_ng.constants import QueueDownloadStatus from tidal_dl_ng.helper.tidal import ( get_tidal_media_id, get_tidal_media_type, instantiate_media, name_builder_artist, + name_builder_title, quality_audio_highest, search_results_all, url_ending_clean, ) from tidal_dl_ng.logger import logger_gui -from tidal_dl_ng.model.gui_data import ResultItem +from tidal_dl_ng.model.gui_data import QueueDownloadItem, ResultItem if TYPE_CHECKING: from tidal_dl_ng.gui import MainWindow @@ -36,77 +39,142 @@ class GuiSearchManager: self.main_window.populate_tree_results(results) def search(self, query: str, types_media: list[Any]) -> list[ResultItem]: - """Perform a search and return a list of ResultItems.""" - query_clean = query.strip() + """Perform a search and return a list of ResultItems. + + Args: + query (str): The search query. + types_media (list[Any]): The types of media to search for. + + Returns: + list[ResultItem]: The search results. + """ + query_clean: str = query.strip() + + # If a direct link was searched for, skip search and create the object from the link directly. if "http" in query_clean: - query_clean = url_ending_clean(query_clean) + query_clean: str = url_ending_clean(query_clean) media_type = get_tidal_media_type(query_clean) item_id = get_tidal_media_id(query_clean) + try: media = instantiate_media(self.main_window.tidal.session, media_type, item_id) - result_search = {"direct": [media]} - except Exception: + except: logger_gui.error(f"Media not found (ID: {item_id}). Maybe it is not available anymore.") - result_search = {"direct": []} + + media = None + + result_search = {"direct": [media]} else: - result_search = search_results_all( + result_search: dict[str, list[SearchTypes]] = search_results_all( session=self.main_window.tidal.session, needle=query_clean, types_media=types_media ) - result = [] + + result: list[ResultItem] = [] + for _media_type, l_media in result_search.items(): if isinstance(l_media, list): - result.extend(self.search_result_to_model(l_media)) + result = result + self.search_result_to_model(l_media) + return result def search_result_to_model(self, items: list[SearchTypes]) -> list[ResultItem]: - """Convert search results to ResultItem models.""" - return [self._to_result_item(idx, item) for idx, item in enumerate(items) if self._to_result_item(idx, item)] + """Convert search results to ResultItem models. + + Args: + items (list[SearchTypes]): List of search result items. + + Returns: + list[ResultItem]: List of ResultItem models. + """ + result: list[ResultItem] = [] + + for idx, item in enumerate(items): + result_item = self._to_result_item(idx, item) + + if result_item is not None: + result.append(result_item) + + return result - def _to_result_item(self, idx: int, item: Any) -> ResultItem | None: - """Helper to convert a single item to ResultItem.""" + def _to_result_item(self, idx: int, item) -> ResultItem | None: + """Helper to convert a single item to ResultItem, or None if not valid. + + Args: + idx (int): Index of the item. + item: The item to convert. + + Returns: + ResultItem | None: The converted ResultItem or None if not valid. + """ if not item or (hasattr(item, "available") and not item.available): return None + # 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 = self._get_date_release(item) + # Map item types to their conversion methods type_handlers = { - Track: self._result_item_from_track, - Video: self._result_item_from_video, - Playlist: self._result_item_from_playlist, - Album: self._result_item_from_album, - Mix: self._result_item_from_mix, - Artist: self._result_item_from_artist, - Folder: self._result_item_from_folder, + 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), } + # Find and execute the appropriate handler for item_type, handler in type_handlers.items(): if isinstance(item, item_type): - return handler(idx, item, explicit, date_user_added, date_release) + return handler() + return None - def _get_date_release(self, item: Any) -> str: - """Get the release date string for an item.""" + def _get_date_release(self, item) -> str: + """Get the release date string for an item. + + Args: + item: The item to extract the release date from. + + Returns: + str: The formatted release date or empty string. + """ if hasattr(item, "album") and item.album and getattr(item.album, "release_date", None): return item.album.release_date.strftime("%Y-%m-%d_%H:%M") + if hasattr(item, "release_date") and item.release_date: return item.release_date.strftime("%Y-%m-%d_%H:%M") + return "" def _result_item_from_track( - self, idx: int, item: Track, explicit: str, date_user_added: str, date_release: str + self, idx: int, item, explicit: str, date_user_added: str, date_release: str ) -> ResultItem: - """Create a ResultItem from a Track.""" + """Create a ResultItem from a Track. + + Args: + idx (int): Index of the item. + item: The Track item. + explicit (str): Explicit tag. + date_user_added (str): Date user added. + date_release (str): Release date. + + Returns: + ResultItem: The constructed ResultItem. + """ + final_quality = quality_audio_highest(item) if hasattr(item, "audio_modes") and AudioMode.dolby_atmos.value in item.audio_modes: final_quality = f"{final_quality} / Dolby Atmos" + return ResultItem( position=idx, artist=name_builder_artist(item), - title=f"{item.name}{explicit}", + title=f"{name_builder_title(item)}{explicit}", album=item.album.name, duration_sec=item.duration, obj=item, @@ -117,13 +185,24 @@ class GuiSearchManager: ) def _result_item_from_video( - self, idx: int, item: Video, explicit: str, date_user_added: str, date_release: str + self, idx: int, item, explicit: str, date_user_added: str, date_release: str ) -> ResultItem: - """Create a ResultItem from a Video.""" + """Create a ResultItem from a Video. + + Args: + idx (int): Index of the item. + item: The Video item. + explicit (str): Explicit tag. + date_user_added (str): Date user added. + date_release (str): Release date. + + Returns: + ResultItem: The constructed ResultItem. + """ return ResultItem( position=idx, artist=name_builder_artist(item), - title=f"{item.name}{explicit}", + title=f"{name_builder_title(item)}{explicit}", album=item.album.name if item.album else "", duration_sec=item.duration, obj=item, @@ -133,10 +212,18 @@ class GuiSearchManager: date_release=date_release, ) - def _result_item_from_playlist( - self, idx: int, item: Playlist, explicit: str, date_user_added: str, date_release: str - ) -> ResultItem: - """Create a ResultItem from a Playlist.""" + def _result_item_from_playlist(self, idx: int, item, date_user_added: str, date_release: str) -> ResultItem: + """Create a ResultItem from a Playlist. + + Args: + idx (int): Index of the item. + item: The Playlist item. + date_user_added (str): Date user added. + date_release (str): Release date. + + Returns: + ResultItem: The constructed ResultItem. + """ return ResultItem( position=idx, artist=", ".join(artist.name for artist in item.promoted_artists) if item.promoted_artists else "", @@ -151,9 +238,20 @@ class GuiSearchManager: ) def _result_item_from_album( - self, idx: int, item: Album, explicit: str, date_user_added: str, date_release: str + self, idx: int, item, explicit: str, date_user_added: str, date_release: str ) -> ResultItem: - """Create a ResultItem from an Album.""" + """Create a ResultItem from an Album. + + Args: + idx (int): Index of the item. + item: The Album item. + explicit (str): Explicit tag. + date_user_added (str): Date user added. + date_release (str): Release date. + + Returns: + ResultItem: The constructed ResultItem. + """ return ResultItem( position=idx, artist=name_builder_artist(item), @@ -167,16 +265,24 @@ class GuiSearchManager: date_release=date_release, ) - def _result_item_from_mix( - self, idx: int, item: Mix, explicit: str, date_user_added: str, date_release: str - ) -> ResultItem: - """Create a ResultItem from a Mix.""" + def _result_item_from_mix(self, idx: int, item, date_user_added: str, date_release: str) -> ResultItem: + """Create a ResultItem from a Mix. + + Args: + idx (int): Index of the item. + item: The Mix item. + date_user_added (str): Date user added. + date_release (str): Release date. + + Returns: + ResultItem: The constructed ResultItem. + """ return ResultItem( position=idx, artist=item.sub_title, title=item.title, album="", - duration_sec=-1, + duration_sec=-1, # TODO: Calculate total duration. obj=item, quality="", explicit=False, @@ -184,10 +290,18 @@ class GuiSearchManager: date_release=date_release, ) - def _result_item_from_artist( - self, idx: int, item: Artist, explicit: str, date_user_added: str, date_release: str - ) -> ResultItem: - """Create a ResultItem from an Artist.""" + def _result_item_from_artist(self, idx: int, item, date_user_added: str, date_release: str) -> ResultItem: + """Create a ResultItem from an Artist. + + Args: + idx (int): Index of the item. + item: The Artist item. + date_user_added (str): Date user added. + date_release (str): Release date. + + Returns: + ResultItem: The constructed ResultItem. + """ return ResultItem( position=idx, artist=item.name, @@ -201,11 +315,18 @@ class GuiSearchManager: date_release=date_release, ) - def _result_item_from_folder( - self, idx: int, item: Folder, explicit: str, date_user_added: str, date_release: str - ) -> ResultItem: - """Create a ResultItem from a Folder.""" - total_items = item.total_number_of_items if hasattr(item, "total_number_of_items") else 0 + 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="", @@ -216,5 +337,65 @@ class GuiSearchManager: quality="", explicit=False, date_user_added=date_user_added, - date_release=date_release, + date_release="", ) + + def media_to_queue_download_model( + self, media: Artist | Track | Video | Album | Playlist | Mix + ) -> QueueDownloadItem | bool: + """Convert a media object to a QueueDownloadItem for the download queue. + + Args: + media (Artist | Track | Video | Album | Playlist | Mix): The media object. + + Returns: + QueueDownloadItem | bool: The queue item or False if not available. + """ + result: QueueDownloadItem | False + name: str = "" + quality_audio: Quality = self.main_window.settings.data.quality_audio + quality_video: QualityVideo = self.main_window.settings.data.quality_video + explicit: str = "" + + # Check if item is available on TIDAL. + # Note: Some albums have available=None, which should be treated as available + if hasattr(media, "available") and media.available is False: + return False + + # Set "Explicit" tag + if isinstance(media, Track | Video | Album): + explicit = " 🅴" if media.explicit else "" + + # Build name and set quality + if isinstance(media, Track | Video): + name = f"{name_builder_artist(media)} - {name_builder_title(media)}{explicit}" + elif isinstance(media, Playlist | Artist): + name = media.name + elif isinstance(media, Album): + name = f"{name_builder_artist(media)} - {media.name}{explicit}" + elif isinstance(media, Mix): + name = media.title + + # Determine actual quality. + if isinstance(media, Track | Album): + quality_highest: Quality = quality_audio_highest(media) + + if ( + self.main_window.settings.data.quality_audio == quality_highest + or self.main_window.settings.data.quality_audio == Quality.hi_res_lossless + ): + quality_audio = quality_highest + + if name: + result = QueueDownloadItem( + name=name, + quality_audio=quality_audio, + quality_video=quality_video, + type_media=type(media).__name__, + status=QueueDownloadStatus.Waiting, + obj=media, + ) + else: + result = False + + return result diff --git a/tidal_dl_ng/history.py b/tidal_dl_ng/history.py new file mode 100644 index 0000000..2431879 --- /dev/null +++ b/tidal_dl_ng/history.py @@ -0,0 +1,437 @@ +""" +history.py + +Implements the download history tracking service with JSON persistence. + +Classes: + HistoryService: Main service for managing download history with atomic JSON operations. +""" + +import json +import os +import shutil +import tempfile +from dataclasses import dataclass +from datetime import UTC, datetime +from pathlib import Path +from threading import Lock +from typing import Any + +from tidal_dl_ng.helper.decorator import SingletonMeta +from tidal_dl_ng.helper.path import path_config_base + + +class HistoryFormatError(TypeError): + """Exception raised when history file format is invalid.""" + + def __init__(self) -> None: + """Initialize with predefined message.""" + super().__init__("Invalid history file format") + + +@dataclass +class DownloadHistoryEntry: + """Represents a single entry in the download history. + + Attributes: + source_type: Type of source (playlist, album, manual, mix). + source_id: ID of the source (UUID or None for manual). + source_name: Name of the source (playlist name, album name, etc.). + download_date: ISO 8601 timestamp of download. + """ + + source_type: str + source_id: str | None + source_name: str | None + download_date: str + + +class HistoryService(metaclass=SingletonMeta): + """Service for managing download history with JSON persistence. + + This service provides thread-safe operations for tracking downloaded tracks. + The history is stored in a track-centric JSON file (trackId -> metadata). + All write operations are atomic to prevent corruption. + + Attributes: + history_data: In-memory dictionary of track IDs to history entries. + file_path: Path to the JSON history file. + _lock: Thread lock for concurrent access safety. + """ + + SCHEMA_VERSION = 1 + + def __init__(self): + """Initialize the history service and load existing data.""" + self.file_path: Path = Path(path_config_base()) / "downloaded_history.json" + self.history_data: dict[str, dict[str, Any]] = {} + self.settings_data: dict[str, Any] = {"preventDuplicates": True} + self._lock: Lock = Lock() + self._load_history() + + def _load_history(self) -> None: + """Load history from JSON file. + + If the file doesn't exist, creates a new empty history. + If the file is corrupted, backs it up and starts fresh. + Thread-safe operation. + """ + with self._lock: + try: + if not self.file_path.exists(): + # Create directory if needed + self.file_path.parent.mkdir(parents=True, exist_ok=True) + # Initialize with empty schema + self._save_history_internal() + return + + with open(self.file_path, encoding="utf-8") as f: + data = json.load(f) + + if not isinstance(data, dict): + raise HistoryFormatError + + settings = data.get("settings", {}) + self.settings_data = {"preventDuplicates": bool(settings.get("preventDuplicates", True))} + + tracks_section = data.get("tracks") + if isinstance(tracks_section, dict): + self.history_data = tracks_section + else: + self.history_data = {k: v for k, v in data.items() if not k.startswith("_") and k != "settings"} + + except (json.JSONDecodeError, ValueError, FileNotFoundError): + # Backup corrupted file + if self.file_path.exists(): + backup_path = self.file_path.with_suffix(".json.bak") + counter = 1 + while backup_path.exists(): + backup_path = self.file_path.with_suffix(f".json.bak.{counter}") + counter += 1 + shutil.copy2(self.file_path, backup_path) + print(f"Warning: Download history file was corrupted. Backup saved to: {backup_path}") + + # Start with empty history + self.history_data = {} + self.settings_data = {"preventDuplicates": True} + self._save_history_internal() + + def _save_history_internal(self) -> None: + """Internal method to save history to JSON file atomically. + + Uses atomic write (write to temp file, then rename) to prevent corruption. + Assumes lock is already held by caller. + """ + try: + # Ensure directory exists + self.file_path.parent.mkdir(parents=True, exist_ok=True) + + # Prepare data with metadata + data_to_save = { + "_schema_version": self.SCHEMA_VERSION, + "_last_updated": datetime.now(UTC).isoformat(), + "settings": self.settings_data, + "tracks": self.history_data, + } + + # Atomic write: write to temp file, then rename + with tempfile.NamedTemporaryFile( + mode="w", encoding="utf-8", dir=self.file_path.parent, delete=False, suffix=".tmp" + ) as tmp_file: + json.dump(data_to_save, tmp_file, indent=2, ensure_ascii=False) + tmp_path = tmp_file.name + + # Atomic rename (on Windows, need to remove target first if exists) + if os.name == "nt" and self.file_path.exists(): + os.replace(tmp_path, self.file_path) + else: + os.rename(tmp_path, self.file_path) + + except Exception: + # Clean up temp file if it exists + if "tmp_path" in locals() and os.path.exists(tmp_path): + from contextlib import suppress + + with suppress(OSError): + os.remove(tmp_path) + raise + + def save_history(self) -> None: + """Public method to save history (with lock acquisition).""" + with self._lock: + self._save_history_internal() + + def get_settings(self) -> dict[str, Any]: + """Return a copy of history-related settings.""" + with self._lock: + return dict(self.settings_data) + + def update_settings(self, **kwargs: Any) -> None: + """Update history settings and persist immediately.""" + with self._lock: + for key, value in kwargs.items(): + if key in self.settings_data: + self.settings_data[key] = bool(value) + self._save_history_internal() + + def should_skip_download(self, track_id: str) -> bool: + """Return True if track should be skipped based on history and settings.""" + with self._lock: + prevent_duplicates = self.settings_data.get("preventDuplicates", True) + return prevent_duplicates and track_id in self.history_data + + def is_downloaded(self, track_id: str) -> bool: + """Check if a track has been downloaded. + + Args: + track_id: The TIDAL track ID to check. + + Returns: + True if track is in download history, False otherwise. + """ + with self._lock: + return track_id in self.history_data + + def add_track_to_history( + self, track_id: str, source_type: str = "manual", source_id: str | None = None, source_name: str | None = None + ) -> None: + """Add a track to download history. + + Args: + track_id: The TIDAL track ID. + source_type: Type of source (playlist, album, manual, mix, track). + source_id: ID of the source (UUID for playlist/album, None for manual). + source_name: Name of the source (for display purposes). + """ + with self._lock: + self.history_data[track_id] = { + "sourceType": source_type, + "sourceId": source_id, + "sourceName": source_name, + "downloadDate": datetime.now(UTC).isoformat(), + } + # Save immediately to persist + self._save_history_internal() + + def remove_track_from_history(self, track_id: str) -> bool: + """Remove a track from download history. + + Args: + track_id: The TIDAL track ID to remove. + + Returns: + True if track was removed, False if not found. + """ + with self._lock: + if track_id in self.history_data: + del self.history_data[track_id] + self._save_history_internal() + return True + return False + + def get_history_by_source(self) -> dict[str, list[dict[str, Any]]]: + """Transform track-centric history to source-centric view. + + Returns: + Dictionary grouped by source with format: + { + "source_key": [ + { + "track_id": "123", + "download_date": "2025-11-15T...", + ... + } + ] + } + where source_key is "{sourceType}_{sourceId}" or "manual" for manual downloads. + """ + with self._lock: + grouped: dict[str, list[dict[str, Any]]] = {} + + for track_id, entry in self.history_data.items(): + source_type = entry.get("sourceType", "manual") + source_id = entry.get("sourceId") + source_name = entry.get("sourceName", "Unknown") + + # Create source key + source_key = f"{source_type}_{source_id}" if source_id else f"{source_type}_manual" + + # Initialize list if needed + if source_key not in grouped: + grouped[source_key] = [] + + # Add track info + grouped[source_key].append( + { + "track_id": track_id, + "source_type": source_type, + "source_id": source_id, + "source_name": source_name, + "download_date": entry.get("downloadDate", ""), + } + ) + + return grouped + + def get_track_info(self, track_id: str) -> dict[str, Any] | None: + """Get download history info for a specific track. + + Args: + track_id: The TIDAL track ID. + + Returns: + Dictionary with history entry or None if not found. + """ + with self._lock: + return self.history_data.get(track_id) + + def get_history_file_path(self) -> str: + """Get the absolute path to the history file. + + Returns: + Absolute path as string. + """ + return str(self.file_path.absolute()) + + def _extract_tracks_from_data(self, data: dict) -> dict: + """Extract tracks from import data. + + Args: + data: Import data dictionary. + + Returns: + Dictionary of tracks. + """ + tracks_node = data.get("tracks") + if isinstance(tracks_node, dict): + return tracks_node + return {k: v for k, v in data.items() if not k.startswith("_") and k != "settings"} + + def _validate_tracks(self, tracks: dict) -> tuple[bool, str]: + """Validate track entries. + + Args: + tracks: Dictionary of tracks to validate. + + Returns: + Tuple of (valid, error_message). + """ + required_keys = {"sourceType", "downloadDate"} + for track_id, entry in tracks.items(): + if not isinstance(entry, dict): + return False, f"Invalid entry format for track {track_id}" + if not required_keys.issubset(entry.keys()): + return False, f"Missing required fields for track {track_id}" + return True, "" + + def import_history(self, file_path: str, merge: bool = True) -> tuple[bool, str]: + """Import history from an external JSON file. + + Args: + file_path: Path to the JSON file to import. + merge: If True, merge with existing history. If False, replace. + + Returns: + Tuple of (success: bool, message: str). + """ + try: + with open(file_path, encoding="utf-8") as f: + data = json.load(f) + except json.JSONDecodeError as e: + return False, f"Invalid JSON file: {e!s}" + except Exception as e: + return False, f"Import failed: {e!s}" + else: + # Validate format + if not isinstance(data, dict): + return False, "Invalid file format: expected JSON object" + + # Extract and validate + imported_settings = data.get("settings", {}) + imported_tracks = self._extract_tracks_from_data(data) + + valid, error_msg = self._validate_tracks(imported_tracks) + if not valid: + return False, error_msg + + # Apply import + with self._lock: + if merge: + self.history_data.update(imported_tracks) + message = f"Successfully merged {len(imported_tracks)} tracks" + else: + self.history_data = imported_tracks + message = f"Successfully imported {len(imported_tracks)} tracks (replaced existing)" + + if isinstance(imported_settings, dict) and "preventDuplicates" in imported_settings: + self.settings_data["preventDuplicates"] = bool(imported_settings.get("preventDuplicates", True)) + + self._save_history_internal() + + return True, message + + def export_history(self, file_path: str) -> tuple[bool, str]: + """Export history to an external JSON file. + + Args: + file_path: Destination path for the exported JSON file. + + Returns: + Tuple of (success: bool, message: str). + """ + try: + with self._lock: + # Prepare export data (same format as internal storage) + data_to_export = { + "_schema_version": self.SCHEMA_VERSION, + "_exported_date": datetime.now(UTC).isoformat(), + "_total_tracks": len(self.history_data), + "settings": self.settings_data, + "tracks": self.history_data, + } + + with open(file_path, "w", encoding="utf-8") as f: + json.dump(data_to_export, f, indent=2, ensure_ascii=False) + + return True, f"Successfully exported {len(self.history_data)} tracks" + + except Exception as e: + return False, f"Export failed: {e!s}" + + def clear_history(self) -> None: + """Clear all download history. + + This is a destructive operation - use with caution. + """ + with self._lock: + self.history_data = {} + self._save_history_internal() + + def get_statistics(self) -> dict[str, Any]: + """Get statistics about the download history. + + Returns: + Dictionary with statistics (total tracks, by source type, etc.). + """ + with self._lock: + stats = { + "total_tracks": len(self.history_data), + "by_source_type": {}, + "oldest_download": None, + "newest_download": None, + } + + dates = [] + for entry in self.history_data.values(): + source_type = entry.get("sourceType", "unknown") + stats["by_source_type"][source_type] = stats["by_source_type"].get(source_type, 0) + 1 + + download_date = entry.get("downloadDate") + if download_date: + dates.append(download_date) + + if dates: + stats["oldest_download"] = min(dates) + stats["newest_download"] = max(dates) + + return stats diff --git a/tidal_dl_ng/logger.py b/tidal_dl_ng/logger.py index 83477a7..0874cdc 100644 --- a/tidal_dl_ng/logger.py +++ b/tidal_dl_ng/logger.py @@ -52,14 +52,17 @@ handler_qt: QtHandler = QtHandler() # log_fmt: str = "[%(asctime)s] %(levelname)s: %(message)s" log_fmt: str = "> %(message)s" # formatter = logging.Formatter(log_fmt) -formatter = coloredlogs.ColoredFormatter(fmt=log_fmt) +# Configure custom level styles to make INFO messages green +level_styles = coloredlogs.DEFAULT_LEVEL_STYLES.copy() +level_styles["info"] = {"color": "green"} +formatter = coloredlogs.ColoredFormatter(fmt=log_fmt, level_styles=level_styles) handler_qt.setFormatter(formatter) logger_gui.addHandler(handler_qt) logger_gui.setLevel(logging.DEBUG) logger_cli = logging.getLogger(__name__) handler_stream: logging.StreamHandler = logging.StreamHandler() -formatter = coloredlogs.ColoredFormatter(fmt=log_fmt) +formatter = coloredlogs.ColoredFormatter(fmt=log_fmt, level_styles=level_styles) handler_stream.setFormatter(formatter) logger_cli.addHandler(handler_stream) logger_cli.setLevel(logging.DEBUG) diff --git a/tidal_dl_ng/ui/dialog_history.py b/tidal_dl_ng/ui/dialog_history.py new file mode 100644 index 0000000..72c3ae4 --- /dev/null +++ b/tidal_dl_ng/ui/dialog_history.py @@ -0,0 +1,171 @@ +################################################################################ +## Form generated from reading UI file 'dialog_history.ui' +## +## Created by: Qt User Interface Compiler version 6.10.0 +## +## WARNING! All changes made in this file will be lost when recompiling UI file! +################################################################################ + +from PySide6.QtCore import QCoreApplication, QMetaObject +from PySide6.QtWidgets import ( + QAbstractItemView, + QGroupBox, + QHBoxLayout, + QLabel, + QLineEdit, + QPushButton, + QSizePolicy, + QSpacerItem, + QTreeWidget, + QVBoxLayout, +) + + +class Ui_DialogHistory: + def setupUi(self, DialogHistory): + if not DialogHistory.objectName(): + DialogHistory.setObjectName("DialogHistory") + DialogHistory.resize(900, 600) + self.verticalLayout = QVBoxLayout(DialogHistory) + self.verticalLayout.setObjectName("verticalLayout") + self.l_info = QLabel(DialogHistory) + self.l_info.setObjectName("l_info") + self.l_info.setWordWrap(True) + + self.verticalLayout.addWidget(self.l_info) + + self.gb_file_info = QGroupBox(DialogHistory) + self.gb_file_info.setObjectName("gb_file_info") + self.horizontalLayout = QHBoxLayout(self.gb_file_info) + self.horizontalLayout.setObjectName("horizontalLayout") + self.le_file_path = QLineEdit(self.gb_file_info) + self.le_file_path.setObjectName("le_file_path") + self.le_file_path.setReadOnly(True) + + self.horizontalLayout.addWidget(self.le_file_path) + + self.pb_open_folder = QPushButton(self.gb_file_info) + self.pb_open_folder.setObjectName("pb_open_folder") + + self.horizontalLayout.addWidget(self.pb_open_folder) + + self.verticalLayout.addWidget(self.gb_file_info) + + self.gb_statistics = QGroupBox(DialogHistory) + self.gb_statistics.setObjectName("gb_statistics") + self.horizontalLayout_2 = QHBoxLayout(self.gb_statistics) + self.horizontalLayout_2.setObjectName("horizontalLayout_2") + self.l_total_tracks = QLabel(self.gb_statistics) + self.l_total_tracks.setObjectName("l_total_tracks") + + self.horizontalLayout_2.addWidget(self.l_total_tracks) + + self.l_by_albums = QLabel(self.gb_statistics) + self.l_by_albums.setObjectName("l_by_albums") + + self.horizontalLayout_2.addWidget(self.l_by_albums) + + self.l_by_playlists = QLabel(self.gb_statistics) + self.l_by_playlists.setObjectName("l_by_playlists") + + self.horizontalLayout_2.addWidget(self.l_by_playlists) + + self.l_by_mixes = QLabel(self.gb_statistics) + self.l_by_mixes.setObjectName("l_by_mixes") + + self.horizontalLayout_2.addWidget(self.l_by_mixes) + + self.l_by_manual = QLabel(self.gb_statistics) + self.l_by_manual.setObjectName("l_by_manual") + + self.horizontalLayout_2.addWidget(self.l_by_manual) + + self.horizontalSpacer = QSpacerItem(40, 20, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum) + + self.horizontalLayout_2.addItem(self.horizontalSpacer) + + self.verticalLayout.addWidget(self.gb_statistics) + + self.tw_history = QTreeWidget(DialogHistory) + self.tw_history.setObjectName("tw_history") + self.tw_history.setAlternatingRowColors(True) + self.tw_history.setSelectionMode(QAbstractItemView.ExtendedSelection) + self.tw_history.setSortingEnabled(True) + + self.verticalLayout.addWidget(self.tw_history) + + self.horizontalLayout_3 = QHBoxLayout() + self.horizontalLayout_3.setObjectName("horizontalLayout_3") + self.pb_export = QPushButton(DialogHistory) + self.pb_export.setObjectName("pb_export") + + self.horizontalLayout_3.addWidget(self.pb_export) + + self.pb_import = QPushButton(DialogHistory) + self.pb_import.setObjectName("pb_import") + + self.horizontalLayout_3.addWidget(self.pb_import) + + self.pb_clear_history = QPushButton(DialogHistory) + self.pb_clear_history.setObjectName("pb_clear_history") + self.pb_clear_history.setStyleSheet("background-color: #dc3545; color: white;") + + self.horizontalLayout_3.addWidget(self.pb_clear_history) + + self.pb_remove_selected = QPushButton(DialogHistory) + self.pb_remove_selected.setObjectName("pb_remove_selected") + + self.horizontalLayout_3.addWidget(self.pb_remove_selected) + + self.horizontalSpacer_2 = QSpacerItem(40, 20, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum) + + self.horizontalLayout_3.addItem(self.horizontalSpacer_2) + + self.pb_refresh = QPushButton(DialogHistory) + self.pb_refresh.setObjectName("pb_refresh") + + self.horizontalLayout_3.addWidget(self.pb_refresh) + + self.pb_close = QPushButton(DialogHistory) + self.pb_close.setObjectName("pb_close") + + self.horizontalLayout_3.addWidget(self.pb_close) + + self.verticalLayout.addLayout(self.horizontalLayout_3) + + self.retranslateUi(DialogHistory) + + QMetaObject.connectSlotsByName(DialogHistory) + + # setupUi + + def retranslateUi(self, DialogHistory): + DialogHistory.setWindowTitle(QCoreApplication.translate("DialogHistory", "Download History", None)) + self.l_info.setText( + QCoreApplication.translate( + "DialogHistory", + "View and manage your download history. Tracks are grouped by source (album, playlist, mix).", + None, + ) + ) + self.gb_file_info.setTitle(QCoreApplication.translate("DialogHistory", "History File Location", None)) + self.pb_open_folder.setText(QCoreApplication.translate("DialogHistory", "Open Folder", None)) + self.gb_statistics.setTitle(QCoreApplication.translate("DialogHistory", "Statistics", None)) + self.l_total_tracks.setText(QCoreApplication.translate("DialogHistory", "Total Tracks: 0", None)) + self.l_by_albums.setText(QCoreApplication.translate("DialogHistory", "Albums: 0", None)) + self.l_by_playlists.setText(QCoreApplication.translate("DialogHistory", "Playlists: 0", None)) + self.l_by_mixes.setText(QCoreApplication.translate("DialogHistory", "Mixes: 0", None)) + self.l_by_manual.setText(QCoreApplication.translate("DialogHistory", "Manual: 0", None)) + ___qtreewidgetitem = self.tw_history.headerItem() + ___qtreewidgetitem.setText(3, QCoreApplication.translate("DialogHistory", "Track ID", None)) + ___qtreewidgetitem.setText(2, QCoreApplication.translate("DialogHistory", "Download Date", None)) + ___qtreewidgetitem.setText(1, QCoreApplication.translate("DialogHistory", "Type", None)) + ___qtreewidgetitem.setText(0, QCoreApplication.translate("DialogHistory", "Source / Track", None)) + self.pb_export.setText(QCoreApplication.translate("DialogHistory", "Export...", None)) + self.pb_import.setText(QCoreApplication.translate("DialogHistory", "Import...", None)) + self.pb_clear_history.setText(QCoreApplication.translate("DialogHistory", "Clear History", None)) + self.pb_remove_selected.setText(QCoreApplication.translate("DialogHistory", "Remove Selected", None)) + self.pb_refresh.setText(QCoreApplication.translate("DialogHistory", "Refresh", None)) + self.pb_close.setText(QCoreApplication.translate("DialogHistory", "Close", None)) + + # retranslateUi diff --git a/tidal_dl_ng/ui/dialog_history.ui b/tidal_dl_ng/ui/dialog_history.ui new file mode 100644 index 0000000..4d1209d --- /dev/null +++ b/tidal_dl_ng/ui/dialog_history.ui @@ -0,0 +1,206 @@ +<?xml version="1.0" encoding="UTF-8"?> +<ui version="4.0"> + <class>DialogHistory</class> + <widget class="QDialog" name="DialogHistory"> + <property name="geometry"> + <rect> + <x>0</x> + <y>0</y> + <width>900</width> + <height>600</height> + </rect> + </property> + <property name="windowTitle"> + <string>Download History</string> + </property> + <layout class="QVBoxLayout" name="verticalLayout"> + <item> + <widget class="QLabel" name="l_info"> + <property name="text"> + <string>View and manage your download history. Tracks are grouped by source (album, playlist, mix).</string> + </property> + <property name="wordWrap"> + <bool>true</bool> + </property> + </widget> + </item> + <item> + <widget class="QGroupBox" name="gb_file_info"> + <property name="title"> + <string>History File Location</string> + </property> + <layout class="QHBoxLayout" name="horizontalLayout"> + <item> + <widget class="QLineEdit" name="le_file_path"> + <property name="readOnly"> + <bool>true</bool> + </property> + </widget> + </item> + <item> + <widget class="QPushButton" name="pb_open_folder"> + <property name="text"> + <string>Open Folder</string> + </property> + </widget> + </item> + </layout> + </widget> + </item> + <item> + <widget class="QGroupBox" name="gb_statistics"> + <property name="title"> + <string>Statistics</string> + </property> + <layout class="QHBoxLayout" name="horizontalLayout_2"> + <item> + <widget class="QLabel" name="l_total_tracks"> + <property name="text"> + <string>Total Tracks: 0</string> + </property> + </widget> + </item> + <item> + <widget class="QLabel" name="l_by_albums"> + <property name="text"> + <string>Albums: 0</string> + </property> + </widget> + </item> + <item> + <widget class="QLabel" name="l_by_playlists"> + <property name="text"> + <string>Playlists: 0</string> + </property> + </widget> + </item> + <item> + <widget class="QLabel" name="l_by_mixes"> + <property name="text"> + <string>Mixes: 0</string> + </property> + </widget> + </item> + <item> + <widget class="QLabel" name="l_by_manual"> + <property name="text"> + <string>Manual: 0</string> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>40</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + </layout> + </widget> + </item> + <item> + <widget class="QTreeWidget" name="tw_history"> + <property name="alternatingRowColors"> + <bool>true</bool> + </property> + <property name="selectionMode"> + <enum>QAbstractItemView::ExtendedSelection</enum> + </property> + <property name="sortingEnabled"> + <bool>true</bool> + </property> + <column> + <property name="text"> + <string>Source / Track</string> + </property> + </column> + <column> + <property name="text"> + <string>Type</string> + </property> + </column> + <column> + <property name="text"> + <string>Download Date</string> + </property> + </column> + <column> + <property name="text"> + <string>Track ID</string> + </property> + </column> + </widget> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_3"> + <item> + <widget class="QPushButton" name="pb_export"> + <property name="text"> + <string>Export...</string> + </property> + </widget> + </item> + <item> + <widget class="QPushButton" name="pb_import"> + <property name="text"> + <string>Import...</string> + </property> + </widget> + </item> + <item> + <widget class="QPushButton" name="pb_clear_history"> + <property name="text"> + <string>Clear History</string> + </property> + <property name="styleSheet"> + <string notr="true">background-color: #dc3545; color: white;</string> + </property> + </widget> + </item> + <item> + <widget class="QPushButton" name="pb_remove_selected"> + <property name="text"> + <string>Remove Selected</string> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer_2"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>40</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="QPushButton" name="pb_refresh"> + <property name="text"> + <string>Refresh</string> + </property> + </widget> + </item> + <item> + <widget class="QPushButton" name="pb_close"> + <property name="text"> + <string>Close</string> + </property> + </widget> + </item> + </layout> + </item> + </layout> + </widget> + <resources/> + <connections/> +</ui> diff --git a/tidal_dl_ng/ui/dialog_settings.py b/tidal_dl_ng/ui/dialog_settings.py index 266c4d1..2521d08 100644 --- a/tidal_dl_ng/ui/dialog_settings.py +++ b/tidal_dl_ng/ui/dialog_settings.py @@ -106,7 +106,8 @@ class Ui_DialogSettings: "}\n" "\n" "QWidget {\n" - " background-color: transparent;\n" + " background-color: #2b2b2b;\n" + " color: #e0e0e0;\n" "}\n" "\n" "QGroupBox {\n" @@ -124,6 +125,165 @@ class Ui_DialogSettings: " padding: 4px 8px;\n" " background-color: #3a3a3a;\n" " border-radius: 4px;\n" + "}\n" + "\n" + "QComboBox {\n" + " background-color: #2b2b2b;\n" + " border: 1px solid #3d3d3d;\n" + " border-radius: 4px;\n" + " padding: 4px 8px;\n" + " color: #e0e0e0;\n" + " min-height: 20px;\n" + "}\n" + "\n" + "QComboBox:hover {\n" + " border: 1px solid #4a4a4a;\n" + " background-color: #333333;\n" + "}\n" + "\n" + "QComboBox:focus {\n" + " border: 1px solid #3d5a80;\n" + "}\n" + "\n" + "QComboBox::drop-down {\n" + " border" + ": none;\n" + " width: 20px;\n" + "}\n" + "\n" + "QComboBox QAbstractItemView {\n" + " background-color: #2b2b2b !important;\n" + " border: 1px solid #3d3d3d !important;\n" + " selection-background-color: #3d5a80;\n" + " selection-color: white;\n" + " color: #e0e0e0;\n" + " outline: none;\n" + "}\n" + "\n" + "QComboBox QAbstractItemView * {\n" + " background-color: #2b2b2b !important;\n" + " color: #e0e0e0 !important;\n" + "}\n" + "\n" + "QComboBox QAbstractItemView::item {\n" + " padding: 4px 8px;\n" + " min-height: 20px;\n" + " background-color: transparent;\n" + "}\n" + "\n" + "QComboBox QAbstractItemView::item:hover {\n" + " background-color: #3a3a3a !important;\n" + "}\n" + "\n" + "QComboBox QAbstractItemView::item:selected {\n" + " background-color: #3d5a80 !important;\n" + " color: white !important;\n" + "}\n" + "\n" + "QLineEdit {\n" + " background-color: #2b2b2b;\n" + " border: 1px solid #3d3d3d;\n" + " border-radius: 4px;\n" + " padding: 4px 8px;\n" + " color: #e0e0e0;\n" + "}\n" + "\n" + "QLineEdit:hover {\n" + " border:" + " 1px solid #4a4a4a;\n" + "}\n" + "\n" + "QLineEdit:focus {\n" + " border: 1px solid #3d5a80;\n" + "}\n" + "\n" + "QSpinBox {\n" + " background-color: #2b2b2b;\n" + " border: 1px solid #3d3d3d;\n" + " border-radius: 4px;\n" + " padding: 4px 8px;\n" + " color: #e0e0e0;\n" + "}\n" + "\n" + "QSpinBox:hover {\n" + " border: 1px solid #4a4a4a;\n" + "}\n" + "\n" + "QSpinBox:focus {\n" + " border: 1px solid #3d5a80;\n" + "}\n" + "\n" + "QSpinBox::up-button, QSpinBox::down-button {\n" + " background-color: #3a3a3a;\n" + " border: none;\n" + " width: 16px;\n" + "}\n" + "\n" + "QSpinBox::up-button:hover, QSpinBox::down-button:hover {\n" + " background-color: #4a4a4a;\n" + "}\n" + "\n" + "QSpinBox::up-arrow {\n" + " image: none;\n" + " border-left: 4px solid transparent;\n" + " border-right: 4px solid transparent;\n" + " border-bottom: 5px solid #e0e0e0;\n" + "}\n" + "\n" + "QSpinBox::down-arrow {\n" + " image: none;\n" + " border-left: 4px solid transparent;\n" + " border-right: 4px solid transparent;\n" + " border-top: 5px solid #e0e0e0;\n" + "}\n" + "" + "\n" + "QCheckBox {\n" + " color: #e0e0e0;\n" + " spacing: 8px;\n" + "}\n" + "\n" + "QCheckBox::indicator {\n" + " width: 18px;\n" + " height: 18px;\n" + " border: 1px solid #3d3d3d;\n" + " border-radius: 3px;\n" + " background-color: #2b2b2b;\n" + "}\n" + "\n" + "QCheckBox::indicator:hover {\n" + " border: 1px solid #4a4a4a;\n" + " background-color: #333333;\n" + "}\n" + "\n" + "QCheckBox::indicator:checked {\n" + " background-color: #3d5a80;\n" + " border: 1px solid #3d5a80;\n" + "}\n" + "\n" + "QCheckBox::indicator:checked:hover {\n" + " background-color: #4a6a9a;\n" + " border: 1px solid #4a6a9a;\n" + "}\n" + "\n" + "QPushButton {\n" + " background-color: #3a3a3a;\n" + " border: 1px solid #4a4a4a;\n" + " border-radius: 4px;\n" + " padding: 4px 12px;\n" + " color: #e0e0e0;\n" + "}\n" + "\n" + "QPushButton:hover {\n" + " background-color: #4a4a4a;\n" + "}\n" + "\n" + "QPushButton:pressed {\n" + " background-color: #2a2a2a;\n" + "}\n" + "\n" + "QLabel {\n" + " color: #e0e0e0;\n" "}" ) self.sw_categories.setFrameShape(QFrame.Shape.StyledPanel) @@ -876,7 +1036,7 @@ class Ui_DialogSettings: self.bb_dialog.accepted.connect(DialogSettings.accept) self.bb_dialog.rejected.connect(DialogSettings.reject) - self.sw_categories.setCurrentIndex(0) + self.sw_categories.setCurrentIndex(1) QMetaObject.connectSlotsByName(DialogSettings) @@ -905,10 +1065,13 @@ class Ui_DialogSettings: self.gb_choices.setTitle(QCoreApplication.translate("DialogSettings", "Choices", None)) self.l_icon_quality_audio.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None)) self.l_quality_audio.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None)) + self.c_quality_audio.setPlaceholderText(QCoreApplication.translate("DialogSettings", "text", None)) self.l_icon_quality_video.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None)) self.l_quality_video.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None)) + self.c_quality_video.setPlaceholderText(QCoreApplication.translate("DialogSettings", "text", None)) self.l_icon_metadata_cover_dimension.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None)) self.l_metadata_cover_dimension.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None)) + self.c_metadata_cover_dimension.setPlaceholderText(QCoreApplication.translate("DialogSettings", "text", None)) self.gb_numbers.setTitle(QCoreApplication.translate("DialogSettings", "Numbers", None)) self.l_album_track_num_pad_min.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None)) self.l_icon_album_track_num_pad_min.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None)) diff --git a/tidal_dl_ng/ui/dialog_settings.ui b/tidal_dl_ng/ui/dialog_settings.ui index 77dd59a..3f11ebc 100644 --- a/tidal_dl_ng/ui/dialog_settings.ui +++ b/tidal_dl_ng/ui/dialog_settings.ui @@ -147,6 +147,162 @@ QGroupBox::title { padding: 4px 8px; background-color: #3a3a3a; border-radius: 4px; +} + +QComboBox { + background-color: #2b2b2b; + border: 1px solid #3d3d3d; + border-radius: 4px; + padding: 4px 8px; + color: #e0e0e0; + min-height: 20px; +} + +QComboBox:hover { + border: 1px solid #4a4a4a; + background-color: #333333; +} + +QComboBox:focus { + border: 1px solid #3d5a80; +} + +QComboBox::drop-down { + border: none; + width: 20px; +} + +QComboBox QAbstractItemView { + background-color: #2b2b2b !important; + border: 1px solid #3d3d3d !important; + selection-background-color: #3d5a80; + selection-color: white; + color: #e0e0e0; + outline: none; +} + +QComboBox QAbstractItemView * { + background-color: #2b2b2b !important; + color: #e0e0e0 !important; +} + +QComboBox QAbstractItemView::item { + padding: 4px 8px; + min-height: 20px; + background-color: transparent; +} + +QComboBox QAbstractItemView::item:hover { + background-color: #3a3a3a !important; +} + +QComboBox QAbstractItemView::item:selected { + background-color: #3d5a80 !important; + color: white !important; +} + +QLineEdit { + background-color: #2b2b2b; + border: 1px solid #3d3d3d; + border-radius: 4px; + padding: 4px 8px; + color: #e0e0e0; +} + +QLineEdit:hover { + border: 1px solid #4a4a4a; +} + +QLineEdit:focus { + border: 1px solid #3d5a80; +} + +QSpinBox { + background-color: #2b2b2b; + border: 1px solid #3d3d3d; + border-radius: 4px; + padding: 4px 8px; + color: #e0e0e0; +} + +QSpinBox:hover { + border: 1px solid #4a4a4a; +} + +QSpinBox:focus { + border: 1px solid #3d5a80; +} + +QSpinBox::up-button, QSpinBox::down-button { + background-color: #3a3a3a; + border: none; + width: 16px; +} + +QSpinBox::up-button:hover, QSpinBox::down-button:hover { + background-color: #4a4a4a; +} + +QSpinBox::up-arrow { + image: none; + border-left: 4px solid transparent; + border-right: 4px solid transparent; + border-bottom: 5px solid #e0e0e0; +} + +QSpinBox::down-arrow { + image: none; + border-left: 4px solid transparent; + border-right: 4px solid transparent; + border-top: 5px solid #e0e0e0; +} + +QCheckBox { + color: #e0e0e0; + spacing: 8px; +} + +QCheckBox::indicator { + width: 18px; + height: 18px; + border: 1px solid #3d3d3d; + border-radius: 3px; + background-color: #2b2b2b; +} + +QCheckBox::indicator:hover { + border: 1px solid #4a4a4a; + background-color: #333333; +} + +QCheckBox::indicator:checked { + background-color: #3d5a80; + border: 1px solid #3d5a80; +} + +QCheckBox::indicator:checked:hover { + background-color: #4a6a9a; + border: 1px solid #4a6a9a; +} + +QPushButton { + background-color: #3a3a3a; + border: 1px solid #4a4a4a; + border-radius: 4px; + padding: 4px 12px; + color: #e0e0e0; +} + +QPushButton:hover { + background-color: #4a4a4a; +} + +QPushButton:pressed { + background-color: #2a2a2a; +} + +QLabel { + color: #e0e0e0; }</string> </property> <property name="frameShape"> @@ -159,7 +315,7 @@ QGroupBox::title { <number>1</number> </property> <property name="currentIndex"> - <number>0</number> + <number>1</number> </property> <widget class="QWidget" name="page_flags"> <layout class="QVBoxLayout" name="lv_page_flags"> @@ -463,7 +619,11 @@ QGroupBox::title { </widget> </item> <item> - <widget class="QComboBox" name="c_quality_audio"/> + <widget class="QComboBox" name="c_quality_audio"> + <property name="placeholderText"> + <string>text</string> + </property> + </widget> </item> </layout> </item> @@ -496,7 +656,11 @@ QGroupBox::title { </widget> </item> <item> - <widget class="QComboBox" name="c_quality_video"/> + <widget class="QComboBox" name="c_quality_video"> + <property name="placeholderText"> + <string>text</string> + </property> + </widget> </item> </layout> </item> @@ -539,6 +703,9 @@ QGroupBox::title { <verstretch>0</verstretch> </sizepolicy> </property> + <property name="placeholderText"> + <string>text</string> + </property> </widget> </item> </layout> |
