diff options
Diffstat (limited to 'tidal_dl_ng')
| -rw-r--r-- | tidal_dl_ng/cache.py | 55 | ||||
| -rw-r--r-- | tidal_dl_ng/download.py | 169 | ||||
| -rw-r--r-- | tidal_dl_ng/gui.py | 1728 | ||||
| -rw-r--r-- | tidal_dl_ng/gui_covers.py | 161 | ||||
| -rw-r--r-- | tidal_dl_ng/gui_playlist.py | 384 | ||||
| -rw-r--r-- | tidal_dl_ng/gui_queue.py | 296 | ||||
| -rw-r--r-- | tidal_dl_ng/gui_search.py | 220 | ||||
| -rw-r--r-- | tidal_dl_ng/helper/hover_manager.py | 187 | ||||
| -rw-r--r-- | tidal_dl_ng/helper/metadata_utils.py | 227 | ||||
| -rw-r--r-- | tidal_dl_ng/helper/tidal.py | 340 | ||||
| -rw-r--r-- | tidal_dl_ng/metadata.py | 69 | ||||
| -rw-r--r-- | tidal_dl_ng/ui/info_tab_widget.py | 883 | ||||
| -rw-r--r-- | tidal_dl_ng/ui/main.py | 217 | ||||
| -rw-r--r-- | tidal_dl_ng/ui/main.ui | 288 | ||||
| -rw-r--r-- | tidal_dl_ng/ui/media_details_helper.py | 115 |
15 files changed, 3745 insertions, 1594 deletions
diff --git a/tidal_dl_ng/cache.py b/tidal_dl_ng/cache.py new file mode 100644 index 0000000..e9e28df --- /dev/null +++ b/tidal_dl_ng/cache.py @@ -0,0 +1,55 @@ +"""Cache management for GUI - LRU caches for performance optimization.""" + +from PySide6 import QtCore, QtGui + + +class TrackExtrasCache: + """Thread-safe LRU cache for track extra metadata.""" + + def __init__(self, max_size: int = 256): + self._lock = QtCore.QReadWriteLock() + self._data: dict[str, dict] = {} + self._order: list[str] = [] + self._max_size = max_size + + def get(self, track_id: str) -> dict | None: + """Get cached extras for a track ID.""" + with QtCore.QReadLocker(self._lock): + return self._data.get(track_id) + + def set(self, track_id: str, extras: dict) -> None: + """Cache extras for a track ID with LRU eviction.""" + with QtCore.QWriteLocker(self._lock): + if track_id in self._data: + self._order.remove(track_id) + self._data[track_id] = extras + self._order.append(track_id) + if len(self._order) > self._max_size: + oldest = self._order.pop(0) + self._data.pop(oldest, None) + + +class CoverPixmapCache: + """Thread-safe LRU cache for cover pixmaps to avoid re-downloading.""" + + def __init__(self, max_size: int = 100): + self._lock = QtCore.QReadWriteLock() + self._pixmaps: dict[str, QtGui.QPixmap] = {} + self._order: list[str] = [] + self._max_size = max_size + + def get(self, cover_url: str) -> QtGui.QPixmap | None: + """Get cached pixmap for a cover URL.""" + with QtCore.QReadLocker(self._lock): + return self._pixmaps.get(cover_url) + + def set(self, cover_url: str, pixmap: QtGui.QPixmap) -> None: + """Cache a pixmap with LRU eviction.""" + with QtCore.QWriteLocker(self._lock): + if cover_url in self._pixmaps: + self._order.remove(cover_url) + self._pixmaps[cover_url] = pixmap + self._order.append(cover_url) + if len(self._order) > self._max_size: + oldest = self._order.pop(0) + self._pixmaps.pop(oldest, None) diff --git a/tidal_dl_ng/download.py b/tidal_dl_ng/download.py index 728b45f..a121f95 100644 --- a/tidal_dl_ng/download.py +++ b/tidal_dl_ng/download.py @@ -8,6 +8,7 @@ Classes: Download: Main class for managing downloads, segment merging, file operations, and metadata. """ +import contextlib import os import pathlib import random @@ -63,12 +64,16 @@ from tidal_dl_ng.helper.path import ( url_to_filename, ) from tidal_dl_ng.helper.tidal import ( + extract_contributor_names, + # new helpers for enriched metadata + fetch_raw_track_and_album, instantiate_media, items_results_all, name_builder_album_artist, name_builder_artist, name_builder_item, name_builder_title, + parse_track_and_album_extras, ) from tidal_dl_ng.metadata import Metadata from tidal_dl_ng.model.downloader import DownloadSegmentResult, TrackStreamInfo @@ -1272,67 +1277,123 @@ class Download: Returns: tuple[bool, pathlib.Path | None, pathlib.Path | None]: (Success, path to lyrics, path to cover) """ - result: bool = False - path_lyrics: pathlib.Path | None = None - path_cover: pathlib.Path | None = None - release_date: str = ( - track.album.available_release_date.strftime("%Y-%m-%d") - if track.album.available_release_date - else track.album.release_date.strftime("%Y-%m-%d") if track.album.release_date else "" - ) + release_date = self._release_date_str(track) copy_right: str = track.copyright if hasattr(track, "copyright") and track.copyright else "" isrc: str = track.isrc if hasattr(track, "isrc") and track.isrc else "" - lyrics: str = "" - lyrics_synced: str = "" - lyrics_unsynced: str = "" - cover_data: bytes = None + lyrics, lyrics_synced, lyrics_unsynced, path_lyrics = self._collect_lyrics(track) + cover_data, path_cover = self._collect_cover(track, is_parent_album) + extras = self._fetch_extras(track) + m = self._build_metadata( + track, + media_stream, + release_date, + copy_right, + isrc, + cover_data, + lyrics_synced, + lyrics_unsynced, + extras, + ) + m.path_file = path_media + m.save() + return True, path_lyrics, path_cover - if self.settings.data.lyrics_embed or self.settings.data.lyrics_file: - # Try to retrieve lyrics. - try: - lyrics_obj = track.lyrics() - - if lyrics_obj.text: - lyrics_unsynced = lyrics_obj.text - lyrics = lyrics_unsynced - if lyrics_obj.subtitles: - lyrics_synced = lyrics_obj.subtitles - lyrics = lyrics_synced - except: - lyrics = "" - # TODO: Implement proper logging. - print(f"Could not retrieve lyrics for `{name_builder_item(track)}`.") + def _release_date_str(self, track: Track) -> str: + date = None + if hasattr(track, "album") and track.album is not None: + date = track.album.available_release_date or track.album.release_date + return date.strftime("%Y-%m-%d") if date else "" + def _collect_lyrics(self, track: Track) -> tuple[str, str, str, pathlib.Path | None]: + lyrics = "" + lyrics_synced = "" + lyrics_unsynced = "" + path_lyrics: pathlib.Path | None = None + if not (self.settings.data.lyrics_embed or self.settings.data.lyrics_file): + return lyrics, lyrics_synced, lyrics_unsynced, None + try: + lyrics_obj = track.lyrics() + if getattr(lyrics_obj, "text", None): + lyrics_unsynced = lyrics_obj.text + lyrics = lyrics_unsynced + if getattr(lyrics_obj, "subtitles", None): + lyrics_synced = lyrics_obj.subtitles + lyrics = lyrics_synced + except Exception: + lyrics = "" if lyrics and self.settings.data.lyrics_file: - path_lyrics = self.lyrics_to_file(path_media.parent, lyrics) + path_lyrics = self.lyrics_to_file(pathlib.Path(self.path_base), lyrics) + return lyrics, lyrics_synced, lyrics_unsynced, path_lyrics + def _collect_cover(self, track: Track, is_parent_album: bool) -> tuple[bytes | None, pathlib.Path | None]: + cover_data: bytes | None = None + path_cover: pathlib.Path | None = None + if not (self.settings.data.metadata_cover_embed or (self.settings.data.cover_album_file and is_parent_album)): + return None, None cover_dimension = self.settings.data.metadata_cover_dimension - - if self.settings.data.metadata_cover_embed or (self.settings.data.cover_album_file and is_parent_album): - # Do not write CoverDimensions.PxORIGIN to metadata, since it can exceed max metadata file size (>16Mb) - url_cover = track.album.image( - int(cover_dimension) if cover_dimension != CoverDimensions.PxORIGIN else int(CoverDimensions.Px1280) - ) - cover_data = self.cover_data(url=url_cover) - + dim = int(cover_dimension) if cover_dimension != CoverDimensions.PxORIGIN else int(CoverDimensions.Px1280) + url_cover = track.album.image(dim) + cover_data = self.cover_data(url=url_cover) if url_cover else None if cover_data and self.settings.data.cover_album_file and is_parent_album: if cover_dimension == CoverDimensions.PxORIGIN: url_cover_album_file = track.album.image(CoverDimensions.PxORIGIN) cover_data_album_file = self.cover_data(url=url_cover_album_file) else: cover_data_album_file = cover_data + path_cover = self.cover_to_file(pathlib.Path(self.path_base), cover_data_album_file) + return cover_data, path_cover - path_cover = self.cover_to_file(path_media.parent, cover_data_album_file) + def _fetch_extras(self, track: Track) -> dict: + extras: dict = { + "bpm": None, + "label": "", + "genres": [], + "contributors_by_role": {}, + } + # Use suppress to avoid bare try/except pass + with contextlib.suppress(Exception): + track_json, album_json = fetch_raw_track_and_album( + self.session, str(track.id), extra_params={"include": "contributors,genres"} + ) + parsed = parse_track_and_album_extras(track_json, album_json) + if parsed: + extras.update(parsed) + return extras + def _build_metadata( + self, + track: Track, + media_stream: Stream, + release_date: str, + copy_right: str, + isrc: str, + cover_data: bytes | None, + lyrics_synced: str, + lyrics_unsynced: str, + extras: dict, + ) -> Metadata: metadata_target_upc = MetadataTargetUPC(self.settings.data.metadata_target_upc) target_upc: dict[str, str] = METADATA_LOOKUP_UPC[metadata_target_upc] explicit: bool = track.explicit if hasattr(track, "explicit") else False title = name_builder_title(track) - title += METADATA_EXPLICIT if explicit and self.settings.data.mark_explicit else "" - - # `None` values are not allowed. - m: Metadata = Metadata( - path_file=path_media, + if explicit and self.settings.data.mark_explicit: + title += METADATA_EXPLICIT + genres = extras.get("genres") or [] + genres_clean = [g for g in genres if isinstance(g, str) and g] + genre_display = self.settings.data.metadata_delimiter_artist.join(genres_clean) + contributors_by_role = extras.get("contributors_by_role") or {} + delimiter = self.settings.data.metadata_delimiter_artist + producers = extract_contributor_names(contributors_by_role, "producer", delimiter=delimiter) + composers_detailed = extract_contributor_names( + contributors_by_role, "composer", delimiter=delimiter + ) or extract_contributor_names(contributors_by_role, "composers", delimiter=delimiter) + lyricists = extract_contributor_names( + contributors_by_role, "lyricist", delimiter=delimiter + ) or extract_contributor_names(contributors_by_role, "lyricists", delimiter=delimiter) + bpm_val = extras.get("bpm") + bpm: int | None = int(bpm_val) if isinstance(bpm_val, (int | float)) else None + return Metadata( + path_file=pathlib.Path(self.path_base), target_upc=target_upc, lyrics=lyrics_synced, lyrics_unsynced=lyrics_unsynced, @@ -1356,14 +1417,14 @@ class Download: replay_gain_write=self.settings.data.metadata_replay_gain, upc=track.album.upc if track.album and track.album.upc else "", explicit=explicit, + genre=genre_display, + label=extras.get("label") or "", + bpm=bpm, + producers=producers, + composers_detailed=composers_detailed, + lyricists=lyricists, ) - m.save() - - result = True - - return result, path_lyrics, path_cover - def items( self, file_template: str, @@ -1689,9 +1750,9 @@ class Download: pathlib.Path: Path to the converted MP4 file. """ path_file_out: pathlib.Path = path_file.with_suffix(AudioExtensions.MP4) - + self.fn_logger.debug(f"Converting video: {path_file.name} -> {path_file_out.name}") - + ffmpeg = ( FFmpeg(executable=self.settings.data.path_binary_ffmpeg) .option("y") @@ -1702,7 +1763,7 @@ class Download: ) ffmpeg.execute() - + self.fn_logger.debug(f"Video conversion complete: {path_file_out.name}") return path_file_out @@ -1717,9 +1778,9 @@ class Download: pathlib.Path: Path to the extracted FLAC file. """ path_media_out = path_media_src.with_suffix(AudioExtensions.FLAC) - + self.fn_logger.debug(f"Extracting FLAC: {path_media_src.name} -> {path_media_out.name}") - + ffmpeg = ( FFmpeg(executable=self.settings.data.path_binary_ffmpeg) .option("hide_banner") @@ -1736,7 +1797,7 @@ class Download: ) ffmpeg.execute() - + self.fn_logger.debug(f"FLAC extraction complete: {path_media_out.name}") return path_media_out diff --git a/tidal_dl_ng/gui.py b/tidal_dl_ng/gui.py index df5c3d7..4a22035 100644 --- a/tidal_dl_ng/gui.py +++ b/tidal_dl_ng/gui.py @@ -44,9 +44,11 @@ # nuitka-project: --company-name=exislow +import contextlib import math import sys import time +import urllib.parse from collections.abc import Callable, Iterable, Sequence from typing import Any @@ -58,27 +60,17 @@ from tidal_dl_ng.dialog import DialogLogin, DialogPreferences, DialogVersion 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.path import get_format_template, resource_path +from tidal_dl_ng.helper.hover_manager import HoverManager from tidal_dl_ng.helper.tidal import ( + extract_contributor_names, favorite_function_factory, - get_tidal_media_id, - get_tidal_media_type, - instantiate_media, + fetch_raw_track_and_album, items_results_all, name_builder_artist, - name_builder_title, - quality_audio_highest, - search_results_all, - url_ending_clean, - user_media_lists, + parse_track_and_album_extras, ) try: @@ -89,21 +81,24 @@ 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, UserPlaylist, Video +from tidalapi import Album, Mix, Playlist, Quality, Track, Video from tidalapi.artist import Artist -from tidalapi.media import AudioMode -from tidalapi.playlist import Folder from tidalapi.session import SearchTypes +from tidal_dl_ng.cache import TrackExtrasCache from tidal_dl_ng.config import HandlingApp, Settings, Tidal -from tidal_dl_ng.constants import FAVORITES, QualityVideo, QueueDownloadStatus, TidalLists +from tidal_dl_ng.constants import QualityVideo 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.logger import XStream, logger_gui -from tidal_dl_ng.model.gui_data import ProgressBars, QueueDownloadItem, ResultItem, StatusbarMessage +from tidal_dl_ng.model.gui_data import ProgressBars, 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 from tidal_dl_ng.ui.spinner import QtWaitingSpinner from tidal_dl_ng.worker import Worker @@ -126,8 +121,15 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): shutdown: bool = False model_tr_results: QtGui.QStandardItemModel = QtGui.QStandardItemModel() proxy_tr_results: HumanProxyModel + info_tab_widget: InfoTabWidget + hover_manager: HoverManager + queue_manager: GuiQueueManager + playlist_manager: GuiPlaylistManager + search_manager: GuiSearchManager s_spinner_start: QtCore.Signal = QtCore.Signal(QtWidgets.QWidget) s_spinner_stop: QtCore.Signal = QtCore.Signal() + s_track_extras_ready: QtCore.Signal = QtCore.Signal(str, object) # track_id, extras + s_invoke_callback: QtCore.Signal = QtCore.Signal(str, object) # track_id, extras - for InfoTabWidget callback pb_item: QtWidgets.QProgressBar s_item_advance: QtCore.Signal = QtCore.Signal(float) s_item_name: QtCore.Signal = QtCore.Signal(str) @@ -159,30 +161,58 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.setupUi(self) self.setWindowTitle("TIDAL Downloader Next Generation!") + # Initialize settings first + self.settings = Settings() + + # Initialize managers that depend on settings + self.queue_manager = GuiQueueManager(self) + self.playlist_manager = GuiPlaylistManager(self) + self.search_manager = GuiSearchManager(self) + self.info_tab_widget = InfoTabWidget(self, self.tabWidget) + # Logging redirect. XStream.stdout().messageWritten.connect(self._log_output) # XStream.stderr().messageWritten.connect(self._log_output) - self.settings = Settings() - + # Core components self._init_threads() self._init_gui() + self.track_extras_cache = TrackExtrasCache() + self._pending_extras_workers: dict[str, Worker] = {} + self._track_extras_callbacks: dict[str, Callable] = {} # Store callbacks by track_id + + # Managers that have dependencies + self.cover_manager = CoverManager(self, self.threadpool, self.info_tab_widget) + + # Initialize the rest of the UI + self.info_tab_widget.set_track_extras_provider(self.get_track_extras) self._init_tree_results_model(self.model_tr_results) self._init_tree_results(self.tr_results, self.model_tr_results) - self._init_tree_lists(self.tr_lists_user) - self._init_tree_queue(self.tr_queue_download) - self._init_info() + self.playlist_manager.init_ui() + self.queue_manager.init_ui() 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_signals() - self._init_buttons() + + # Connect signal for invoking track extras callbacks + self.s_invoke_callback.connect(self._on_invoke_callback) + self.init_tidal(tidal) 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) + def _init_gui(self) -> None: """Initialize GUI-specific variables and state.""" self.setGeometry( @@ -238,12 +268,12 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): if result: self._init_dl() - self.thread_it(self.tidal_user_lists) + self.thread_it(self.playlist_manager.tidal_user_lists) def _init_threads(self): """Initialize thread pool and start background workers.""" self.threadpool = QtCore.QThreadPool() - self.thread_it(self.watcher_queue_download) + self.thread_it(self.queue_manager.watcher_queue_download) def _init_dl(self): """Initialize Download object and related progress bars.""" @@ -278,11 +308,78 @@ 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") + 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. + + Args: + track_id: The track ID to fetch extras for. + callback: Function to call when async fetch completes. - self.l_pm_cover.setPixmap(QtGui.QPixmap(path_image)) + Returns: + Cached extras dict if available, None if fetching async. + """ + cached = self.track_extras_cache.get(track_id) + if cached is not None: + return cached + + if track_id in self._pending_extras_workers: + return None + + # Store the callback for this track_id + if callback: + self._track_extras_callbacks[track_id] = callback + + def worker() -> None: + extras = None + try: + track_json, album_json = fetch_raw_track_and_album(self.tidal.session, track_id) + extras = parse_track_and_album_extras(track_json, album_json) + extras = self._decorate_extras(extras) + self.track_extras_cache.set(track_id, extras) + except Exception: + extras = None # Return None on errors + finally: + self._pending_extras_workers.pop(track_id, None) + # Emit signal for any listeners + self.s_track_extras_ready.emit(track_id, extras) + # Emit signal to invoke the callback (will be handled by _on_invoke_callback in main thread) + self.s_invoke_callback.emit(track_id, extras) + + worker_obj = Worker(worker) + self._pending_extras_workers[track_id] = worker_obj + self.threadpool.start(worker_obj) + return None + + @QtCore.Slot(str, object) + def _on_invoke_callback(self, track_id: str, extras: dict | None) -> None: + """Invoke the stored callback for a track in the main thread. + + This slot is connected to s_invoke_callback signal and runs in the main GUI thread, + allowing safe UI updates from the callback. + """ + callback = self._track_extras_callbacks.pop(track_id, None) + if callback: + with contextlib.suppress(Exception): + callback(track_id, extras) + + def _decorate_extras(self, extras: dict | None) -> dict: + """Add formatted string fields to extras dict.""" + if not extras: + return {} + result = dict(extras) + result["genres_text"] = ", ".join(result.get("genres", [])) + for role, key in [ + ("producer", "producers_text"), + ("composer", "composers_text"), + ("lyricist", "lyricists_text"), + ]: + result[key] = extract_contributor_names(result.get("contributors_by_role"), role) + return result + + def preload_covers_for_playlist(self, items: list) -> None: + """Preload cover pixmaps for a list of tracks in background.""" + if self.cover_manager: + self.cover_manager.preload_covers_for_playlist(items) def on_progress_reset(self): """Reset progress bars to zero.""" @@ -380,6 +477,18 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): tree.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) tree.customContextMenuRequested.connect(self.menu_context_tree_results) + # Initialize hover manager for track preview + self.hover_manager = HoverManager( + tree_view=tree, + proxy_model=self.proxy_tr_results, + source_model=model, + debounce_delay_ms=50, + parent=self, + ) + # Connect hover signals + self.hover_manager.s_hover_confirmed.connect(self.on_track_hover_confirmed) + self.hover_manager.s_hover_left.connect(self.on_track_hover_left) + def _init_tree_results_model(self, model: QtGui.QStandardItemModel) -> None: """Initialize the model for the results tree view. @@ -392,119 +501,6 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): 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.menu_context_tree_lists) - def on_update_check(self, on_startup: bool = True) -> None: """Check for application updates and emit update signals. @@ -568,51 +564,6 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.spinners.clear() - def menu_context_tree_lists(self, point: QtCore.QPoint) -> None: - """Show context menu for user lists tree. - - Args: - point (QPoint): The point where the menu is requested. - """ - # Infos about the node selected. - index = self.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.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.thread_it(self.on_download_folder_playlists, point) - ) - menu.addAction( - "Download All Albums from Folder", lambda: self.thread_it(self.on_download_folder_albums, point) - ) - elif isinstance(media, str): - # Favorites items (stored as string keys like "fav_tracks", "fav_albums") - menu.addAction("Download All Items", lambda: self.thread_it(self.on_download_favorites, point)) - menu.addAction( - "Download All Albums from Items", lambda: self.thread_it(self.on_download_albums_from_favorites, point) - ) - else: - # Playlist/Mix menu items (existing) - menu.addAction("Download Playlist", lambda: self.thread_download_list_media(point)) - menu.addAction( - "Download All Albums in Playlist", - lambda: self.thread_it(self.on_download_all_albums_from_playlist, point), - ) - menu.addAction("Copy Share URL", lambda: self.on_copy_url_share(self.tr_lists_user, point)) - - menu.exec(self.tr_lists_user.mapToGlobal(point)) - def menu_context_tree_results(self, point: QtCore.QPoint) -> None: """Show context menu for results tree. @@ -640,98 +591,6 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): 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 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.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(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. @@ -778,7 +637,9 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): try: # Add delay every N albums to avoid rate limiting if idx > 1 and (idx - 1) % batch_size == 0: - logger_gui.info(f"🛑 RATE LIMITING: Processed {idx - 1} albums, pausing for {delay_sec} seconds...") + logger_gui.info( + f"ðY>' RATE LIMITING: Processed {idx - 1} albums, pausing for {delay_sec} seconds..." + ) time.sleep(delay_sec) # Check session validity before making API calls @@ -835,7 +696,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): queue_items = [] for album in albums_dict.values(): - queue_dl_item = self.media_to_queue_download_model(album) + queue_dl_item = self.queue_manager.media_to_queue_download_model(album) if queue_dl_item: queue_items.append((queue_dl_item, album)) logger_gui.debug(f"Prepared: {name_builder_artist(album)} - {album.name}") @@ -843,7 +704,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): # Add all items to queue logger_gui.info(f"Adding {len(queue_items)} albums to queue...") for queue_dl_item, album in queue_items: - self.queue_download_media(queue_dl_item) + self.queue_manager.queue_download_media(queue_dl_item) logger_gui.info(f"Added: {name_builder_artist(album)} - {album.name}") def on_copy_url_share( @@ -871,304 +732,6 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): clipboard.clear() clipboard.setText(url_share) - def on_download_list_media(self, point: QtCore.QPoint | None = None) -> None: - """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.tr_lists_user.itemAt(point)] - else: - items = self.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: QueueDownloadItem | None = self.media_to_queue_download_model(media) - - if queue_dl_item: - self.queue_download_media(queue_dl_item) - - def on_download_folder_playlists(self, point: QtCore.QPoint) -> None: - """Download all playlists in a folder. - - Args: - point (QPoint): The point in the tree where the folder was right-clicked. - """ - try: - # Get and validate the folder - item = self.tr_lists_user.itemAt(point) - media = get_user_list_media_item(item) - - if not isinstance(media, Folder): - logger_gui.error("Please select a folder.") - return - - # Fetch all playlists in the folder - logger_gui.info(f"Fetching playlists from folder: {media.name}") - playlists = self._get_folder_playlists(media) - - if not playlists: - logger_gui.info(f"No playlists found in folder: {media.name}") - return - - # Queue each playlist for download - logger_gui.info(f"Queueing {len(playlists)} playlists from folder: {media.name}") - - for playlist in playlists: - queue_dl_item: QueueDownloadItem | None = self.media_to_queue_download_model(playlist) - - if queue_dl_item: - self.queue_download_media(queue_dl_item) - - logger_gui.info(f"✅ Successfully queued {len(playlists)} playlists from folder: {media.name}") - - except Exception as e: - logger_gui.exception(f"Error downloading playlists from folder: {e}") - logger_gui.error("Failed to download playlists from folder. See log for details.") - - def on_download_folder_albums(self, point: QtCore.QPoint) -> None: - """Download all unique albums from all playlists in a folder. - - Args: - point (QPoint): The point in the tree where the folder was right-clicked. - """ - try: - # Get and validate the folder - item = self.tr_lists_user.itemAt(point) - media = get_user_list_media_item(item) - - if not isinstance(media, Folder): - logger_gui.error("Please select a folder.") - return - - # Fetch all playlists in the folder - logger_gui.info(f"Fetching playlists from folder: {media.name}") - playlists = self._get_folder_playlists(media) - - if not playlists: - logger_gui.info(f"No playlists found in folder: {media.name}") - return - - logger_gui.info(f"Found {len(playlists)} playlists in folder: {media.name}") - - # Collect all tracks from all playlists - all_tracks: list[Track] = [] - - for playlist in playlists: - try: - tracks = self._get_playlist_tracks(playlist) - all_tracks.extend(tracks) - logger_gui.debug(f"Collected {len(tracks)} tracks from playlist: {playlist.name}") - except Exception as e: - logger_gui.error(f"Error getting tracks from playlist '{playlist.name}': {e}") - continue - - if not all_tracks: - logger_gui.info(f"No tracks found in folder playlists: {media.name}") - return - - logger_gui.info(f"Collected {len(all_tracks)} total tracks from all playlists") - - # Extract unique album IDs - album_ids = self._extract_album_ids_from_tracks(all_tracks) - logger_gui.info(f"Found {len(album_ids)} unique albums across all playlists in folder: {media.name}") - - if not album_ids: - logger_gui.info("No albums found to download.") - return - - # Load full album objects with rate limiting - albums_dict = self._load_albums_with_rate_limiting(album_ids) - - if not albums_dict: - logger_gui.error("Failed to load any albums.") - return - - # Queue the albums for download - self._queue_loaded_albums(albums_dict) - - logger_gui.info(f"✅ Successfully queued {len(albums_dict)} unique albums from folder: {media.name}") - - except Exception as e: - logger_gui.exception(f"Error downloading albums from folder: {e}") - logger_gui.error("Failed to download albums from folder. See log for details.") - - def on_download_favorites(self, point: QtCore.QPoint) -> None: - """Download all items from a Favorites category. - - Args: - point (QPoint): The point in the tree where the favorites item was right-clicked. - """ - try: - # Get and validate the favorites item - item = self.tr_lists_user.itemAt(point) - media = get_user_list_media_item(item) - - if not isinstance(media, str): - logger_gui.error("Please select a favorites category.") - return - - # Get the favorites category name for logging - favorite_name = FAVORITES.get(media, {}).get("name", media) - logger_gui.info(f"Fetching all items from favorites: {favorite_name}") - - # Use the factory to get the appropriate favorites function - favorite_function = favorite_function_factory(self.tidal, media) - - # Fetch all items from this favorites category - media_items = favorite_function() - - if not media_items: - logger_gui.info(f"No items found in favorites: {favorite_name}") - return - - logger_gui.info(f"Found {len(media_items)} items in favorites: {favorite_name}") - - # Queue each item for download - queued_count = 0 - - for media_item in media_items: - queue_dl_item: QueueDownloadItem | None = self.media_to_queue_download_model(media_item) - - if queue_dl_item: - self.queue_download_media(queue_dl_item) - queued_count += 1 - - logger_gui.info(f"✅ Successfully queued {queued_count} items from favorites: {favorite_name}") - - except Exception as e: - logger_gui.exception(f"Error downloading favorites: {e}") - logger_gui.error("Failed to download favorites. See log for details.") - - def _download_albums_from_favorites_albums(self, media_items: list, favorite_name: str) -> None: - """Download albums from favorite albums list. - - Args: - media_items (list): List of favorite albums. - favorite_name (str): Name of the favorites category for logging. - """ - logger_gui.info(f"Queueing {len(media_items)} albums from favorites: {favorite_name}") - albums_dict = {album.id: album for album in media_items if isinstance(album, Album) and album.id} - self._queue_loaded_albums(albums_dict) - logger_gui.info(f"✅ Successfully queued {len(albums_dict)} albums from favorites: {favorite_name}") - - def _download_albums_from_favorites_artists(self, media_items: list, favorite_name: str) -> None: - """Download albums from favorite artists list. - - Args: - media_items (list): List of favorite artists. - favorite_name (str): Name of the favorites category for logging. - """ - logger_gui.info(f"Fetching albums from {len(media_items)} artists...") - all_albums = {} - - for artist in media_items: - if isinstance(artist, Artist): - try: - artist_albums = items_results_all(artist) - for album in artist_albums: - if isinstance(album, Album) and album.id: - all_albums[album.id] = album - logger_gui.debug(f"Found {len(artist_albums)} albums from artist: {artist.name}") - except Exception as e: - logger_gui.error(f"Error getting albums from artist '{artist.name}': {e}") - continue - - if not all_albums: - logger_gui.info("No albums found from favorite artists.") - return - - logger_gui.info(f"Found {len(all_albums)} unique albums from favorite artists") - self._queue_loaded_albums(all_albums) - logger_gui.info(f"✅ Successfully queued {len(all_albums)} albums from favorites: {favorite_name}") - - def _download_albums_from_favorites_tracks(self, media_items: list, favorite_name: str) -> None: - """Download albums from favorite tracks/videos/mixes list. - - Args: - media_items (list): List of favorite tracks/videos/mixes. - favorite_name (str): Name of the favorites category for logging. - """ - logger_gui.info("Extracting albums from tracks...") - album_ids = self._extract_album_ids_from_tracks(media_items) - - if not album_ids: - logger_gui.info(f"No albums found in favorites: {favorite_name}") - return - - logger_gui.info(f"Found {len(album_ids)} unique albums. Loading with rate limiting...") - - # Load full album objects with rate limiting - albums_dict = self._load_albums_with_rate_limiting(album_ids) - - if not albums_dict: - logger_gui.error("Failed to load any albums from favorites.") - return - - # Queue the albums for download - self._queue_loaded_albums(albums_dict) - logger_gui.info(f"✅ Successfully queued {len(albums_dict)} unique albums from favorites: {favorite_name}") - - def on_download_albums_from_favorites(self, point: QtCore.QPoint) -> None: - """Download all unique albums from items in a Favorites category. - - Args: - point (QPoint): The point in the tree where the favorites item was right-clicked. - """ - try: - # Get and validate the favorites item - item = self.tr_lists_user.itemAt(point) - media = get_user_list_media_item(item) - - if not isinstance(media, str): - logger_gui.error("Please select a favorites category.") - return - - # Get the favorites category name for logging - favorite_name = FAVORITES.get(media, {}).get("name", media) - logger_gui.info(f"Fetching all items from favorites: {favorite_name}") - - # Use the factory to get the appropriate favorites function - favorite_function = favorite_function_factory(self.tidal, media) - - # Fetch all items from this favorites category - media_items = favorite_function() - - if not media_items: - logger_gui.info(f"No items found in favorites: {favorite_name}") - return - - logger_gui.info(f"Found {len(media_items)} items in favorites: {favorite_name}") - - # Delegate to appropriate handler based on favorites type - if media == "fav_albums": - self._download_albums_from_favorites_albums(media_items, favorite_name) - elif media == "fav_artists": - self._download_albums_from_favorites_artists(media_items, favorite_name) - else: - self._download_albums_from_favorites_tracks(media_items, favorite_name) - - except Exception as e: - logger_gui.exception(f"Error downloading albums from favorites: {e}") - logger_gui.error("Failed to download albums from favorites. See log for details.") - - def search_populate_results(self, query: str, type_media: Any) -> None: - """Populate the results tree with search results. - - Args: - query (str): The search query. - type_media (SearchTypes): The type of media to search for. - """ - results: list[ResultItem] = self.search(query, [type_media]) - - self.populate_tree_results(results) - def populate_tree_results(self, results: list[ResultItem], parent: QtGui.QStandardItem | None = None) -> None: """Populate the results tree with ResultItem objects. @@ -1260,387 +823,18 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.apply_settings(self.settings) self._init_dl() - def search(self, query: str, types_media: list[Any]) -> list[ResultItem]: - """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: 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.tidal.session, media_type, item_id) - except: - logger_gui.error(f"Media not found (ID: {item_id}). Maybe it is not available anymore.") - - media = None - - result_search = {"direct": [media]} - else: - result_search: dict[str, list[SearchTypes]] = search_results_all( - session=self.tidal.session, needle=query_clean, types_media=types_media - ) - - result: list[ResultItem] = [] - - for _media_type, l_media in result_search.items(): - if isinstance(l_media, list): - 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. - - 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) -> 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: 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() - - return None - - 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, explicit: str, date_user_added: str, date_release: str - ) -> ResultItem: - """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"{name_builder_title(item)}{explicit}", - album=item.album.name, - duration_sec=item.duration, - obj=item, - quality=final_quality, - explicit=bool(item.explicit), - date_user_added=date_user_added, - date_release=date_release, - ) - - def _result_item_from_video( - self, idx: int, item, explicit: str, date_user_added: str, date_release: str - ) -> ResultItem: - """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"{name_builder_title(item)}{explicit}", - album=item.album.name if item.album else "", - duration_sec=item.duration, - obj=item, - quality=item.video_quality, - explicit=bool(item.explicit), - date_user_added=date_user_added, - date_release=date_release, - ) - - 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 "", - title=item.name, - album="", - duration_sec=item.duration, - obj=item, - quality="", - explicit=False, - date_user_added=date_user_added, - date_release=date_release, - ) - - def _result_item_from_album( - self, idx: int, item, explicit: str, date_user_added: str, date_release: str - ) -> ResultItem: - """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), - title="", - album=f"{item.name}{explicit}", - duration_sec=item.duration, - obj=item, - quality=quality_audio_highest(item), - explicit=bool(item.explicit), - date_user_added=date_user_added, - date_release=date_release, - ) - - 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, # TODO: Calculate total duration. - obj=item, - quality="", - explicit=False, - date_user_added=date_user_added, - date_release=date_release, - ) - - 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, - title="", - album="", - duration_sec=-1, - obj=item, - quality="", - explicit=False, - date_user_added=date_user_added, - date_release=date_release, - ) - - def _result_item_from_folder(self, idx: int, item: Folder, date_user_added: str) -> ResultItem: - """Create a ResultItem from a Folder. - - Args: - idx (int): Index of the item. - item (Folder): The Folder item. - date_user_added (str): Date user added. - - Returns: - ResultItem: The constructed ResultItem. - """ - total_items: int = item.total_number_of_items if hasattr(item, "total_number_of_items") else 0 - return ResultItem( - position=idx, - artist="", - title=f"📁 {item.name} ({total_items} items)", - album="", - duration_sec=-1, - obj=item, - quality="", - explicit=False, - date_user_added=date_user_added, - date_release="", - ) - - def media_to_queue_download_model( - self, media: Artist | Track | Video | Album | Playlist | Mix - ) -> QueueDownloadItem | bool: - """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.settings.data.quality_audio - quality_video: QualityVideo = self.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.settings.data.quality_audio == quality_highest - or self.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 - def _init_signals(self) -> None: """Connect signals to their respective slots.""" self.pb_download.clicked.connect(lambda: self.thread_it(self.on_download_results)) - self.pb_download_list.clicked.connect(lambda: self.thread_it(self.on_download_list_media)) - self.pb_reload_user_lists.clicked.connect(lambda: self.thread_it(self.tidal_user_lists)) - self.pb_queue_download_clear_all.clicked.connect(self.on_queue_download_clear_all) - self.pb_queue_download_clear_finished.clicked.connect(self.on_queue_download_clear_finished) - self.pb_queue_download_remove.clicked.connect(self.on_queue_download_remove) - self.pb_queue_download_toggle.clicked.connect(self.on_pb_queue_download_toggle) + self.pb_download_list.clicked.connect(lambda: self.thread_it(self.playlist_manager.on_download_list_media)) self.l_search.returnPressed.connect( - lambda: self.search_populate_results(self.l_search.text(), self.cb_search_type.currentData()) + lambda: self.search_manager.search_populate_results(self.l_search.text(), self.cb_search_type.currentData()) ) self.pb_search.clicked.connect( - lambda: self.search_populate_results(self.l_search.text(), self.cb_search_type.currentData()) + lambda: self.search_manager.search_populate_results(self.l_search.text(), self.cb_search_type.currentData()) ) self.cb_quality_audio.currentIndexChanged.connect(self.on_quality_set_audio) self.cb_quality_video.currentIndexChanged.connect(self.on_quality_set_video) - self.tr_lists_user.itemClicked.connect(self.on_list_items_show) - self.tr_lists_user.itemExpanded.connect(self.on_tr_lists_user_expanded) self.s_spinner_start[QtWidgets.QWidget].connect(self.on_spinner_start) self.s_spinner_stop.connect(self.on_spinner_stop) self.s_item_advance.connect(self.on_progress_item) @@ -1648,8 +842,6 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.s_list_name.connect(self.on_progress_list_name) self.s_list_advance.connect(self.on_progress_list) self.s_pb_reset.connect(self.on_progress_reset) - self.s_populate_tree_lists.connect(self.on_populate_tree_lists) - self.s_populate_folder_children.connect(self.on_populate_folder_children) self.s_statusbar_message.connect(self.on_statusbar_message) self.s_tr_results_add_top_level_item.connect(self.on_tr_results_add_top_level_item) self.s_settings_save.connect(self.on_settings_save) @@ -1669,16 +861,55 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.tr_results.clicked.connect(self.on_result_item_clicked) self.tr_results.doubleClicked.connect(lambda: self.thread_it(self.on_download_results)) - # Download Queue - self.tr_queue_download.itemClicked.connect(self.on_queue_download_item_clicked) - self.s_queue_download_item_downloading.connect(self.on_queue_download_item_downloading) - self.s_queue_download_item_finished.connect(self.on_queue_download_item_finished) - self.s_queue_download_item_failed.connect(self.on_queue_download_item_failed) - self.s_queue_download_item_skipped.connect(self.on_queue_download_item_skipped) + # Managers + self.queue_manager.connect_signals() + self.playlist_manager.connect_signals() + self.info_tab_widget.s_search_in_app.connect(self.on_search_in_app) + self.info_tab_widget.s_search_in_browser.connect(self.on_search_in_browser) - def _init_buttons(self) -> None: - """Initialize the state of the download buttons.""" - self.pb_queue_download_run() + def on_search_in_app(self, search_term: str, search_type: str): + """Perform a search within the application, selecting the correct category.""" + self.l_search.setText(search_term) + + # Map the search type string to the corresponding class + search_type_map = { + "artist": Artist, + "album": Album, + "track": Track, + "video": Video, + "playlist": Playlist, + } + # Default to the current selection if the type is unknown + search_category = search_type_map.get(search_type.lower(), self.cb_search_type.currentData()) + + # Find the index of the category in the combobox and set it + for i in range(self.cb_search_type.count()): + if self.cb_search_type.itemData(i) == search_category: + self.cb_search_type.setCurrentIndex(i) + break + + self.search_manager.search_populate_results(search_term, search_category) + + def on_search_in_browser(self, search_term: str, search_type: str): + """Open a search in the default web browser.""" + safe_term = urllib.parse.quote(search_term) + # Map the search type to the URL path component + search_path_map = { + "artist": "artists", + "album": "albums", + "track": "tracks", + "video": "videos", + "playlist": "playlists", + } + # Use a generic search if the type is not in the map + search_path = search_path_map.get(search_type.lower()) + + if search_path: + url = QtCore.QUrl(f"https://listen.tidal.com/search/{search_path}?q={safe_term}") + else: + url = QtCore.QUrl(f"https://listen.tidal.com/search?q={safe_term}") + + QtGui.QDesktopServices.openUrl(url) def on_logout(self) -> None: """Log out from TIDAL and close the application.""" @@ -1745,206 +976,6 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): if self.tidal: self.tidal.settings_apply() - def on_tr_lists_user_expanded(self, item: QtWidgets.QTreeWidgetItem) -> None: - """Handle expansion of folders in the user lists tree. - - Args: - item (QTreeWidgetItem): The expanded tree item. - """ - # Check if it's a first-time expansion (has disabled dummy child) - if item.childCount() > 0 and item.child(0).isDisabled(): - # Run in thread to avoid blocking UI - self.thread_it(self.tr_lists_user_load_folder_children, item) - - def tr_lists_user_load_folder_children(self, parent_item: QtWidgets.QTreeWidgetItem) -> None: - """Load and display children of a folder in the user lists tree. - - Args: - parent_item (QTreeWidgetItem): The parent folder item. - """ - folder: Folder | None = get_user_list_media_item(parent_item) - - if not isinstance(folder, Folder): - return - - # Show spinner while loading - self.s_spinner_start.emit(self.tr_lists_user) - - try: - # Fetch folder contents - folders, playlists = self._fetch_folder_contents(folder) - - # Emit signal to populate in main thread - self.s_populate_folder_children.emit(parent_item, folders, playlists) - - finally: - self.s_spinner_stop.emit() - - def on_populate_folder_children( - self, parent_item: QtWidgets.QTreeWidgetItem, folders: list[Folder], playlists: list[Playlist] - ) -> None: - """Populate folder children in the main thread (signal handler). - - Args: - parent_item (QTreeWidgetItem): The parent folder item. - folders (list[Folder]): List of sub-folders. - playlists (list[Playlist]): List of playlists. - """ - # Remove dummy child - parent_item.takeChild(0) - - # Add sub-folders as children - for sub_folder in folders: - twi_child = QtWidgets.QTreeWidgetItem(parent_item) - twi_child.setText(0, f"📁 {sub_folder.name}") - set_user_list_media(twi_child, sub_folder) - info = f"({sub_folder.total_number_of_items} items)" if sub_folder.total_number_of_items else "" - twi_child.setText(2, info) - - # Add dummy child for potential sub-folders - dummy = QtWidgets.QTreeWidgetItem(twi_child) - dummy.setDisabled(True) - - # Add playlists as children - for playlist in playlists: - twi_child = QtWidgets.QTreeWidgetItem(parent_item) - name = playlist.name if playlist.name else "" - twi_child.setText(0, name) - set_user_list_media(twi_child, playlist) - info = f"({playlist.num_tracks + playlist.num_videos} Tracks)" - if playlist.description: - info += f" {playlist.description}" - twi_child.setText(2, info) - - def _fetch_folder_contents(self, folder: Folder) -> tuple[list[Folder], list[Playlist]]: - """Fetch contents (sub-folders and playlists) of a folder. - - Args: - folder (Folder): The folder to fetch contents for. - - Returns: - tuple[list[Folder], list[Playlist]]: Sub-folders and playlists within the folder. - """ - folder_id = folder.id if folder.id else "root" - - # Fetch sub-folders with manual pagination - offset = 0 - limit = 50 - folders = [] - - while True: - batch = self.tidal.session.user.favorites.playlist_folders( - limit=limit, offset=offset, parent_folder_id=folder_id - ) - if not batch: - break - folders.extend(batch) - if len(batch) < limit: - break - offset += limit - - # Fetch playlists in this folder using folder.items() method - offset = 0 - playlists = [] - - while True: - batch = folder.items(offset=offset, limit=limit) - if not batch: - break - playlists.extend(batch) - if len(batch) < limit: - break - offset += limit - - return folders, playlists - - def _get_folder_playlists(self, folder: Folder) -> list[Playlist]: - """Fetch all playlists from a folder. - - Args: - folder (Folder): The folder to fetch playlists from. - - Returns: - list[Playlist]: List of playlists in the folder. - """ - # Use existing method to fetch folder contents - # Since folders can't contain folders, we ignore the folders return value - _, playlists = self._fetch_folder_contents(folder) - - logger_gui.debug(f"Found {len(playlists)} playlists in folder: {folder.name}") - - return playlists - - def _get_playlist_tracks(self, playlist: Playlist | UserPlaylist | Mix) -> list[Track]: - """Fetch all tracks from a playlist. - - Args: - playlist (Playlist | UserPlaylist | Mix): The playlist to fetch tracks from. - - Returns: - list[Track]: List of tracks in the playlist. - """ - playlist_name = getattr(playlist, "name", "unknown") - logger_gui.debug(f"Fetching tracks from playlist: {playlist_name}") - media_items = items_results_all(playlist) - - # Filter for Track objects only (items_results_all may return Videos too) - tracks = [item for item in media_items if isinstance(item, Track)] - - logger_gui.debug(f"Found {len(tracks)} tracks in playlist: {playlist_name}") - - return tracks - - def on_list_items_show(self, item: QtWidgets.QTreeWidgetItem) -> None: - """Show the items in the selected playlist or mix. - - Args: - item (QtWidgets.QTreeWidgetItem): The selected tree widget item. - """ - self.thread_it(self.list_items_show, item) - - def list_items_show(self, item: QtWidgets.QTreeWidgetItem) -> None: - """Fetch and display the items in a playlist, 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.s_spinner_start.emit(self.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.tidal, media_list) - self.list_items_show_result(favorite_function=function_list) - else: - self.list_items_show_result(media_list) - # Load cover asynchronously to avoid blocking the GUI - self.thread_it(self.cover_show, media_list) - finally: - self.s_spinner_stop.emit() - - def _show_folder_contents(self, folder: Folder) -> None: - """Display folder contents (nested playlists/folders) in results pane. - - Args: - folder (Folder): The folder to display contents for. - """ - # Fetch folder contents using the shared helper method - folders, playlists = self._fetch_folder_contents(folder) - - # Combine folders and playlists - items = folders + playlists - - # Convert to ResultItems and display - result = self.search_result_to_model(items) - self.populate_tree_results(result) - def on_result_item_clicked(self, index: QtCore.QModelIndex) -> None: """Handle the event when a result item is clicked. @@ -1955,60 +986,11 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): index, self.proxy_tr_results, self.model_tr_results ) - # Load cover asynchronously to avoid blocking the GUI - self.thread_it(self.cover_show, media) - - def on_queue_download_item_clicked(self, item: QtWidgets.QTreeWidgetItem, column: int) -> None: - """Handle the event when a queue download item is clicked. - - Args: - item (QtWidgets.QTreeWidgetItem): The clicked tree widget item. - column (int): The column index of the clicked item. - """ - media: Track | Video | Album | Artist | Mix | Playlist = get_queue_download_media(item) + # Update info tab widget with selected media (persists selection) + self.info_tab_widget.update_on_selection(media) # Load cover asynchronously to avoid blocking the GUI - self.thread_it(self.cover_show, media) - - def cover_show(self, media: Album | Playlist | Track | Video | Album | Artist) -> None: - """Show the cover image of the selected media item. - - Args: - media (Album | Playlist | Track | Video | Album | Artist): The media item. - """ - cover_url: str = "" - # Show spinner in the cover label itself - parent_widget = self.l_pm_cover - - # Show spinner while loading - self.s_spinner_start.emit(parent_widget) - - try: - try: - cover_url = media.album.image() - except Exception: - # Only call image() if it exists - if hasattr(media, "image") and callable(getattr(media, "image", None)): - try: - cover_url = media.image() - except Exception: - logger_gui.info(f"No cover available (media ID: {getattr(media, 'id', 'unknown')}).") - else: - cover_url = None - - logger_gui.info(f"No cover available (media ID: {getattr(media, 'id', 'unknown')}).") - - if cover_url and self.cover_url_current != cover_url: - self.cover_url_current = cover_url - data_cover: bytes = Download.cover_data(cover_url) - pixmap: QtGui.QPixmap = QtGui.QPixmap() - pixmap.loadFromData(data_cover) - self.l_pm_cover.setPixmap(pixmap) - elif not cover_url: - path_image: str = resource_path("tidal_dl_ng/ui/default_album_image.png") - self.l_pm_cover.setPixmap(QtGui.QPixmap(path_image)) - finally: - self.s_spinner_stop.emit() + self.thread_it(self.cover_manager.load_cover, media) def list_items_show_result( self, @@ -2036,9 +1018,9 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): media_items: list[Track | Video | Album] = favorite_function() else: - media_items: list[Track | Video | Album] = items_results_all(media_list) + media_items: list[Track | Video | Album] = items_results_all(self.tidal.session, media_list) - result: list[ResultItem] = self.search_result_to_model(media_items) + result: list[ResultItem] = self.search_manager.search_result_to_model(media_items) self.populate_tree_results(result, parent=parent) @@ -2056,76 +1038,6 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): # Execute self.threadpool.start(worker) - 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})" - ) - - def on_queue_download_clear_finished(self) -> None: - """Clear finished items from the download queue.""" - 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. - - Args: - regex (str): Regular expression to match items. - """ - items: list[QtWidgets.QTreeWidgetItem | None] = self.tr_queue_download.findItems( - regex, QtCore.Qt.MatchFlag.MatchRegularExpression, column=0 - ) - - for item in items: - self.tr_queue_download.takeTopLevelItem(self.tr_queue_download.indexOfTopLevelItem(item)) - - def on_queue_download_remove(self) -> None: - """Remove selected items from the download queue.""" - items: list[QtWidgets.QTreeWidgetItem | None] = self.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.tr_queue_download.takeTopLevelItem(self.tr_queue_download.indexOfTopLevelItem(item)) - else: - 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. - - :return: None - """ - handling_app: HandlingApp = HandlingApp() - - if handling_app.event_run.is_set(): - self.pb_queue_download_pause() - else: - self.pb_queue_download_run() - - 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.pb_queue_download_toggle.setIcon(icon) - self.pb_queue_download_toggle.setStyleSheet("background-color: #e0a800; color: #212529") - - 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.pb_queue_download_toggle.setIcon(icon) - self.pb_queue_download_toggle.setStyleSheet("background-color: #218838; color: #fff") - # TODO: Must happen in main thread. Do not thread this. def on_download_results(self) -> None: """Download the selected results in the results tree.""" @@ -2138,196 +1050,10 @@ 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: QueueDownloadItem = self.media_to_queue_download_model(media) + queue_dl_item = self.queue_manager.media_to_queue_download_model(media) if 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(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) - - 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, - ) - 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, - ) - - # 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 + self.queue_manager.queue_download_media(queue_dl_item) def on_version( self, update_check: bool = False, update_available: bool = False, update_info: ReleaseLatest | None = None @@ -2353,6 +1079,28 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): """ self.thread_it(self.tr_results_expanded, index) + def on_track_hover_confirmed(self, media: Track | Video | Album | Mix | Playlist | Artist) -> None: + """Handle confirmed hover event over a track (after debounce delay).""" + if not media: + return + + # Update info tab widget with hover data + self.info_tab_widget.update_on_hover(media) + + # Load cover using CoverManager (with smart caching) + if self.cover_manager: + self.cover_manager.load_cover(media, use_cache_check=True) + + def on_track_hover_left(self) -> None: + """Handle hover leaving the track list.""" + # Revert to the currently selected media + with contextlib.suppress(Exception): + self.info_tab_widget.revert_to_selection() + + # Reload the cover for the selected media if it exists + if self.info_tab_widget.current_media_selected and self.cover_manager: + self.cover_manager.load_cover(self.info_tab_widget.current_media_selected, use_cache_check=True) + def tr_results_expanded(self, index: QtCore.QModelIndex) -> None: """Load and display the children of an expanded result item. @@ -2391,26 +1139,6 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): self.pb_reload_user_lists.setEnabled(status) self.pb_reload_user_lists.setText(button_text) - def closeEvent(self, event: QtGui.QCloseEvent) -> None: - """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 thread_download_album_from_track(self, point: QtCore.QPoint) -> None: """Starts the download of the full album from a selected track in a new thread. @@ -2437,11 +1165,11 @@ 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: QueueDownloadItem | None = self.media_to_queue_download_model(full_album_object) + queue_dl_item = self.queue_manager.media_to_queue_download_model(full_album_object) if queue_dl_item: # Add the item to the download queue - self.queue_download_media(queue_dl_item) + self.queue_manager.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_covers.py b/tidal_dl_ng/gui_covers.py new file mode 100644 index 0000000..858ab11 --- /dev/null +++ b/tidal_dl_ng/gui_covers.py @@ -0,0 +1,161 @@ +"""Cover management for GUI - Handles cover loading, caching and display.""" + +from contextlib import suppress + +from PySide6 import QtGui + +from tidal_dl_ng.cache import CoverPixmapCache +from tidal_dl_ng.download import Download +from tidal_dl_ng.helper.path import resource_path +from tidal_dl_ng.logger import logger_gui +from tidal_dl_ng.worker import Worker + + +class CoverManager: + """Manages cover art loading, caching and display operations.""" + + def __init__(self, parent_window, threadpool, info_tab_widget): + """Initialize the cover manager. + + Args: + parent_window: Main window instance + threadpool: QThreadPool for async operations + info_tab_widget: InfoTabWidget instance for display + """ + self.parent = parent_window + self.threadpool = threadpool + self.info_tab = info_tab_widget + self.cache = CoverPixmapCache() + self.cover_url_current = "" + + def load_cover(self, media, use_cache_check: bool = True): + """Load and display cover for media item. + + Args: + media: Media object (Track, Album, etc.) + use_cache_check: If True, check cache before loading + """ + if use_cache_check: + # Try cache first for instant display + cover_url = self._get_cover_url(media) + if cover_url: + if cover_url == self.cover_url_current: + return # Already displayed + + cached_pixmap = self.cache.get(cover_url) + if cached_pixmap: + self._display_cover(cached_pixmap, cover_url) + return + + # Load asynchronously + worker = Worker(self._load_cover_async, media) + self.threadpool.start(worker) + + def _load_cover_async(self, media): + """Load cover in background thread.""" + # Emit spinner on tab widget instead of InfoTabWidget (which is QObject, not QWidget) + tab_widget = self.info_tab.tab_widget if hasattr(self.info_tab, "tab_widget") else None + if tab_widget: + self.parent.s_spinner_start.emit(tab_widget) + + try: + cover_url = self._get_cover_url(media) + + if cover_url and self.cover_url_current != cover_url: + # Check cache again (thread-safe) + cached_pixmap = self.cache.get(cover_url) + if cached_pixmap: + self._display_cover(cached_pixmap, cover_url) + else: + # Download and cache + data_cover = Download.cover_data(cover_url) + pixmap = QtGui.QPixmap() + pixmap.loadFromData(data_cover) + self.cache.set(cover_url, pixmap) + self._display_cover(pixmap, cover_url) + elif not cover_url: + self._display_default_cover() + except Exception as e: + logger_gui.warning(f"Failed to load cover: {e}") + self._display_default_cover() + finally: + self.parent.s_spinner_stop.emit() + + def _get_cover_url(self, media) -> str | None: + """Extract cover URL from media object.""" + with suppress(Exception): + if hasattr(media, "album") and media.album: + return media.album.image() + if hasattr(media, "image") and callable(getattr(media, "image", None)): + return media.image() + return None + + def _display_cover(self, pixmap: QtGui.QPixmap, url: str): + """Display a pixmap on the info tab.""" + self.info_tab.set_cover_pixmap(pixmap) + self.info_tab.cover_url_current = url + self.cover_url_current = url + + def _display_default_cover(self): + """Display default cover image.""" + path_image = resource_path("tidal_dl_ng/ui/default_album_image.png") + pixmap = QtGui.QPixmap(path_image) + self.info_tab.set_cover_pixmap(pixmap) + self.info_tab.cover_url_current = "" + self.cover_url_current = "" + + def preload_covers_for_playlist(self, items: list) -> None: + """Preload cover pixmaps for a list of tracks in background. + + Args: + items: List of Track/Video objects to preload covers for. + """ + + def worker() -> None: + # Extract unique cover URLs + cover_urls = set() + for item in items[:50]: # Limit to first 50 + with suppress(Exception): + url = self._get_cover_url(item) + if url: + cover_urls.add(url) + + # Preload each unique cover + for cover_url in cover_urls: + if self.cache.get(cover_url): + continue # Already cached + + with suppress(Exception): + data_cover = Download.cover_data(cover_url) + pixmap = QtGui.QPixmap() + pixmap.loadFromData(data_cover) + self.cache.set(cover_url, pixmap) + logger_gui.debug(f"Preloaded cover: {cover_url[:50]}...") + + worker_obj = Worker(worker) + self.threadpool.start(worker_obj) + + def _queue_cover_fetch(self, media): + """Queue a cover fetch operation for a media item.""" + with suppress(Exception): + # previously try/except/pass + self.threadpool.start(Worker(self.load_cover, media)) + + def _fetch_cover_pixmap(self, media, use_cache_check: bool): + """Fetch cover pixmap for media, with optional cache check.""" + with suppress(Exception): + # previously try/except/continue inside loop + pixmap = self.cache.get(self._get_cover_url(media)) + if pixmap: + return pixmap + + # If not in cache, download cover + cover_url = self._get_cover_url(media) + if cover_url: + data_cover = Download.cover_data(cover_url) + pixmap = QtGui.QPixmap() + pixmap.loadFromData(data_cover) + self.cache.set(cover_url, pixmap) + return pixmap + + return None diff --git a/tidal_dl_ng/gui_playlist.py b/tidal_dl_ng/gui_playlist.py new file mode 100644 index 0000000..9fe97a4 --- /dev/null +++ b/tidal_dl_ng/gui_playlist.py @@ -0,0 +1,384 @@ +# tidal_dl_ng/gui_playlist.py + +from typing import TYPE_CHECKING + +from PySide6 import QtCore, QtWidgets +from tidalapi import Album, Mix, Playlist, UserPlaylist +from tidalapi.playlist import Folder + +from tidal_dl_ng.constants import FAVORITES, TidalLists +from tidal_dl_ng.helper.gui import get_user_list_media_item, set_user_list_media +from tidal_dl_ng.helper.tidal import ( + favorite_function_factory, + items_results_all, + user_media_lists, +) +from tidal_dl_ng.logger import logger_gui +from tidal_dl_ng.model.gui_data import StatusbarMessage + +if TYPE_CHECKING: + from tidal_dl_ng.gui import MainWindow + + +class GuiPlaylistManager: + """Manages the playlist, mixes, and favorites GUI and logic.""" + + def __init__(self, main_window: "MainWindow"): + """Initialize the playlist manager.""" + self.main_window: "MainWindow" = main_window + self.settings = main_window.settings + + def init_ui(self): + """Initialize UI elements related to playlists.""" + self._init_tree_lists(self.main_window.tr_lists_user) + + def connect_signals(self): + """Connect signals for playlist-related widgets.""" + self.main_window.pb_reload_user_lists.clicked.connect(lambda: self.main_window.thread_it(self.tidal_user_lists)) + self.main_window.tr_lists_user.itemClicked.connect(self.on_list_items_show) + self.main_window.tr_lists_user.itemExpanded.connect(self.on_tr_lists_user_expanded) + self.main_window.tr_lists_user.customContextMenuRequested.connect(self.menu_context_tree_lists) + self.main_window.s_populate_tree_lists.connect(self.on_populate_tree_lists) + self.main_window.s_populate_folder_children.connect(self.on_populate_folder_children) + + def _init_tree_lists(self, tree: QtWidgets.QTreeWidget) -> None: + """Initialize the user lists tree widget.""" + tree.setColumnWidth(0, 200) + tree.setColumnHidden(1, True) + tree.setColumnWidth(2, 300) + tree.expandAll() + tree.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + + def tidal_user_lists(self) -> None: + """Fetch and emit user playlists, mixes, and favorites from Tidal.""" + self.main_window.s_spinner_start.emit(self.main_window.tr_lists_user) + self.main_window.s_pb_reload_status.emit(False) + user_all = user_media_lists(self.main_window.tidal.session) + self.main_window.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.""" + twi_playlists = self.main_window.tr_lists_user.findItems(TidalLists.Playlists, QtCore.Qt.MatchExactly, 0)[0] + twi_mixes = self.main_window.tr_lists_user.findItems(TidalLists.Mixes, QtCore.Qt.MatchExactly, 0)[0] + twi_favorites = self.main_window.tr_lists_user.findItems(TidalLists.Favorites, QtCore.Qt.MatchExactly, 0)[0] + + for twi in [twi_playlists, twi_mixes, twi_favorites]: + for i in reversed(range(twi.childCount())): + twi.removeChild(twi.child(i)) + + for item in user_lists.get("playlists", []): + if isinstance(item, Folder): + twi_child = QtWidgets.QTreeWidgetItem(twi_playlists) + twi_child.setText(0, f"📁 {item.name}") + set_user_list_media(twi_child, item) + info = f"({item.total_number_of_items} items)" if item.total_number_of_items else "" + twi_child.setText(2, info) + dummy_child = QtWidgets.QTreeWidgetItem(twi_child) + dummy_child.setDisabled(True) + elif isinstance(item, UserPlaylist | Playlist): + twi_child = QtWidgets.QTreeWidgetItem(twi_playlists) + name = item.name or "" + description = f" {item.description}" if item.description else "" + info = 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) + + for item in user_lists.get("mixes", []): + if isinstance(item, Mix): + twi_child = QtWidgets.QTreeWidgetItem(twi_mixes) + twi_child.setText(0, item.title) + set_user_list_media(twi_child, item) + twi_child.setText(2, item.sub_title) + + for key, favorite in FAVORITES.items(): + twi_child = QtWidgets.QTreeWidgetItem(twi_favorites) + twi_child.setText(0, favorite["name"]) + set_user_list_media(twi_child, key) + + self.main_window.s_spinner_stop.emit() + 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.""" + index = self.main_window.tr_lists_user.indexAt(point) + if not index.isValid() or not index.parent().data(): + return + item = self.main_window.tr_lists_user.itemAt(point) + media = get_user_list_media_item(item) + menu = QtWidgets.QMenu() + if isinstance(media, Folder): + menu.addAction( + "Download All Playlists in Folder", + lambda: self.main_window.thread_it(self.on_download_folder_playlists, point), + ) + menu.addAction( + "Download All Albums from Folder", + lambda: self.main_window.thread_it(self.on_download_folder_albums, point), + ) + elif isinstance(media, str): + 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)) + menu.addAction( + "Download All Albums in Playlist", + lambda: self.main_window.thread_it(self.on_download_all_albums_from_playlist, point), + ) + 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)) + + 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 + 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) + if queue_dl_item: + self.main_window.queue_manager.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}") + + 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) + + 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}") + + def _download_albums_from_fav_artists(self, media_items: list) -> None: + """Download all albums from favorite artists.""" + all_albums = {} + for artist in media_items: + 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 + except Exception as e: + logger_gui.error(f"Error getting albums from artist '{artist.name}': {e}") + if all_albums: + self.main_window._queue_loaded_albums(all_albums) + + def _download_albums_from_fav_tracks(self, media_items: list) -> None: + """Download all albums from favorite tracks.""" + album_ids = self.main_window._extract_album_ids_from_tracks(media_items) + if album_ids: + albums_dict = self.main_window._load_albums_with_rate_limiting(album_ids) + if albums_dict: + 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 + + 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) + + def on_download_all_albums_from_playlist(self, point: QtCore.QPoint) -> None: + """Download all unique albums from tracks in a playlist.""" + item = self.main_window.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 + media_items = items_results_all(self.main_window.tidal.session, media_list) + album_ids = self.main_window._extract_album_ids_from_tracks(media_items) + if not album_ids: + logger_gui.warning("No albums found in this playlist.") + return + 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 playlist.") + return + self.main_window._queue_loaded_albums(albums_dict) + message = f"Added {len(albums_dict)} albums to download queue" + 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.""" + if item.childCount() > 0 and item.child(0).isDisabled(): + 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) + if not isinstance(folder, Folder): + return + self.main_window.s_spinner_start.emit(self.main_window.tr_lists_user) + try: + folders, playlists = self._fetch_folder_contents(folder) + 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.""" + parent_item.takeChild(0) + 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) + dummy = QtWidgets.QTreeWidgetItem(twi_child) + dummy.setDisabled(True) + for playlist in playlists: + twi_child = QtWidgets.QTreeWidgetItem(parent_item) + twi_child.setText(0, playlist.name or "") + set_user_list_media(twi_child, playlist) + info = f"({playlist.num_tracks + playlist.num_videos} Tracks)" + if playlist.description: + info += f" {playlist.description}" + twi_child.setText(2, info) + + def _fetch_folder_contents(self, folder: Folder) -> tuple[list[Folder], list[Playlist]]: + """Fetch contents of a folder.""" + folder_id = folder.id or "root" + folders, playlists = [], [] + offset, limit = 0, 50 + while True: + batch = self.main_window.tidal.session.user.favorites.playlist_folders( + limit=limit, offset=offset, parent_folder_id=folder_id + ) + if not batch: + break + folders.extend(batch) + if len(batch) < limit: + break + offset += limit + offset = 0 + while True: + batch = folder.items(offset=offset, limit=limit) + if not batch: + break + playlists.extend(batch) + if len(batch) < limit: + break + offset += limit + return folders, playlists + + def _get_folder_playlists(self, folder: Folder) -> list[Playlist]: + """Fetch all playlists from a folder.""" + _, playlists = self._fetch_folder_contents(folder) + 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 on_list_items_show(self, item: QtWidgets.QTreeWidgetItem) -> None: + """Show the items in the selected playlist or mix.""" + 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() + + def _show_folder_contents(self, folder: Folder) -> None: + """Display folder contents in results pane.""" + folders, playlists = self._fetch_folder_contents(folder) + items = folders + playlists + result = self.main_window.search_manager.search_result_to_model(items) + self.main_window.search_manager.populate_tree_results(result) diff --git a/tidal_dl_ng/gui_queue.py b/tidal_dl_ng/gui_queue.py new file mode 100644 index 0000000..3abd3f0 --- /dev/null +++ b/tidal_dl_ng/gui_queue.py @@ -0,0 +1,296 @@ +# tidal_dl_ng/gui_queue.py + +import time +from typing import TYPE_CHECKING + +from PySide6 import QtCore, QtGui, QtWidgets +from tidalapi import Album, Artist, Mix, Playlist, Quality, Track, Video + +from tidal_dl_ng.config import HandlingApp, Settings +from tidal_dl_ng.constants import QualityVideo, QueueDownloadStatus +from tidal_dl_ng.download import Download +from tidal_dl_ng.helper.gui import ( + get_queue_download_media, + get_queue_download_quality_audio, + get_queue_download_quality_video, + set_queue_download_media, +) +from tidal_dl_ng.helper.path import get_format_template +from tidal_dl_ng.helper.tidal import items_results_all, name_builder_artist, name_builder_title, quality_audio_highest +from tidal_dl_ng.logger import logger_gui +from tidal_dl_ng.model.gui_data import QueueDownloadItem, StatusbarMessage + +if TYPE_CHECKING: + from tidal_dl_ng.gui import MainWindow + + +class GuiQueueManager: + """Manages the download queue GUI and logic.""" + + def __init__(self, main_window: "MainWindow"): + """Initialize the queue manager.""" + self.main_window: "MainWindow" = main_window + self.settings: Settings = main_window.settings + + def init_ui(self): + """Initialize UI elements related to the queue.""" + self._init_tree_queue(self.main_window.tr_queue_download) + self.pb_queue_download_run() + + def connect_signals(self): + """Connect signals for queue-related widgets.""" + self.main_window.pb_queue_download_clear_all.clicked.connect(self.on_queue_download_clear_all) + self.main_window.pb_queue_download_clear_finished.clicked.connect(self.on_queue_download_clear_finished) + self.main_window.pb_queue_download_remove.clicked.connect(self.on_queue_download_remove) + self.main_window.pb_queue_download_toggle.clicked.connect(self.on_pb_queue_download_toggle) + self.main_window.tr_queue_download.itemClicked.connect(self.on_queue_download_item_clicked) + self.main_window.tr_queue_download.customContextMenuRequested.connect(self.menu_context_queue_download) + self.main_window.s_queue_download_item_downloading.connect(self.on_queue_download_item_downloading) + self.main_window.s_queue_download_item_finished.connect(self.on_queue_download_item_finished) + self.main_window.s_queue_download_item_failed.connect(self.on_queue_download_item_failed) + self.main_window.s_queue_download_item_skipped.connect(self.on_queue_download_item_skipped) + + def _init_tree_queue(self, tree: QtWidgets.QTableWidget) -> None: + """Initialize the download queue 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) + + def menu_context_queue_download(self, point: QtCore.QPoint) -> None: + """Show context menu for download queue.""" + item = self.main_window.tr_queue_download.itemAt(point) + if not item: + return + menu = QtWidgets.QMenu() + status = item.text(0) + if status == QueueDownloadStatus.Waiting: + menu.addAction("🗑️ Remove from Queue", lambda: self.on_queue_download_remove_item(item)) + if menu.isEmpty(): + return + menu.exec(self.main_window.tr_queue_download.mapToGlobal(point)) + + def on_queue_download_remove_item(self, item: QtWidgets.QTreeWidgetItem) -> None: + """Remove a specific item from the download queue.""" + index = self.main_window.tr_queue_download.indexOfTopLevelItem(item) + if index >= 0: + self.main_window.tr_queue_download.takeTopLevelItem(index) + logger_gui.info("Removed item from download queue") + + 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})" + ) + + 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})") + + 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( + 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) + ) + + 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: + 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) + ) + else: + 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.""" + handling_app: HandlingApp = HandlingApp() + if handling_app.event_run.is_set(): + self.pb_queue_download_pause() + else: + self.pb_queue_download_run() + + 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") + + 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") + + def queue_download_media(self, queue_dl_item: QueueDownloadItem) -> None: + """Add a media item to the download queue.""" + child = 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, str(queue_dl_item.quality_audio)) + child.setText(5, str(queue_dl_item.quality_video)) + self.main_window.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 = self.main_window.tr_queue_download.findItems( + QueueDownloadStatus.Waiting, QtCore.Qt.MatchFlag.MatchExactly, column=0 + ) + if items: + item = items[0] + media = get_queue_download_media(item) + quality_audio = get_queue_download_quality_audio(item) + quality_video = get_queue_download_quality_video(item) + try: + self.main_window.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.main_window.s_queue_download_item_finished.emit(item) + elif result == QueueDownloadStatus.Skipped: + self.main_window.s_queue_download_item_skipped.emit(item) + except Exception as e: + logger_gui.error(e) + self.main_window.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'.""" + 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'.""" + 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'.""" + 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'.""" + 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.""" + 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.""" + items_media = items_results_all(self.main_window.tidal.session, media) if isinstance(media, Artist) else [media] + download_delay = bool(isinstance(media, Track | Video) and self.settings.data.download_delay) + result = QueueDownloadStatus.Failed + for item_media in items_media: + result = self.download( + item_media, + self.main_window.dl, + delay_track=download_delay, + quality_audio=quality_audio, + quality_video=quality_video, + ) + return result + + def download( + self, + media: Track | Album | Playlist | Video | Mix, + 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.""" + self.main_window.s_pb_reset.emit() + self.main_window.s_statusbar_message.emit(StatusbarMessage(message="Download started...")) + file_template = get_format_template(media, self.settings) + result_dl, path_file = False, None + 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, + ) + 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, + ) + result_dl, path_file = True, "dummy" + self.main_window.s_statusbar_message.emit(StatusbarMessage(message="Download finished.", timeout=2000)) + if result_dl and path_file: + return QueueDownloadStatus.Finished + if not result_dl and path_file: + return QueueDownloadStatus.Skipped + return QueueDownloadStatus.Failed + + def on_queue_download_item_clicked(self, item: QtWidgets.QTreeWidgetItem) -> None: + """Handle the event when a queue download item is clicked.""" + media = get_queue_download_media(item) + self.main_window.info_tab_widget.update_on_selection(media) + self.main_window.thread_it(self.main_window.cover_manager.load_cover, media) + + def media_to_queue_download_model( + self, media: Artist | Track | Video | Album | Playlist | Mix + ) -> QueueDownloadItem | None: + """Convert a media object to a QueueDownloadItem for the download queue.""" + if hasattr(media, "available") and media.available is False: + return None + explicit = " 🅴" if isinstance(media, Track | Video | Album) and media.explicit else "" + name = "" + 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 + + quality_audio = self.settings.data.quality_audio + if isinstance(media, Track | Album): + quality_highest = quality_audio_highest(media) + if ( + self.settings.data.quality_audio == quality_highest + or self.settings.data.quality_audio == Quality.hi_res_lossless + ): + quality_audio = quality_highest + + if name: + return QueueDownloadItem( + name=name, + quality_audio=quality_audio, + quality_video=self.settings.data.quality_video, + type_media=type(media).__name__, + status=QueueDownloadStatus.Waiting, + obj=media, + ) + return None diff --git a/tidal_dl_ng/gui_search.py b/tidal_dl_ng/gui_search.py new file mode 100644 index 0000000..fb303ad --- /dev/null +++ b/tidal_dl_ng/gui_search.py @@ -0,0 +1,220 @@ +# tidal_dl_ng/gui_search.py + +from typing import TYPE_CHECKING, Any + +from tidalapi import Album, Artist, Mix, Playlist, Track, Video +from tidalapi.media import AudioMode +from tidalapi.playlist import Folder +from tidalapi.session import SearchTypes + +from tidal_dl_ng.helper.tidal import ( + get_tidal_media_id, + get_tidal_media_type, + instantiate_media, + name_builder_artist, + 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 + +if TYPE_CHECKING: + from tidal_dl_ng.gui import MainWindow + + +class GuiSearchManager: + """Manages the search GUI and logic.""" + + def __init__(self, main_window: "MainWindow"): + """Initialize the search manager.""" + self.main_window: "MainWindow" = main_window + + def search_populate_results(self, query: str, type_media: Any) -> None: + """Populate the results tree with search results.""" + results = self.search(query, [type_media]) + 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() + if "http" in query_clean: + query_clean = 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: + logger_gui.error(f"Media not found (ID: {item_id}). Maybe it is not available anymore.") + result_search = {"direct": []} + else: + result_search = search_results_all( + session=self.main_window.tidal.session, needle=query_clean, types_media=types_media + ) + result = [] + for _media_type, l_media in result_search.items(): + if isinstance(l_media, list): + result.extend(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)] + + def _to_result_item(self, idx: int, item: Any) -> ResultItem | None: + """Helper to convert a single item to ResultItem.""" + if not item or (hasattr(item, "available") and not item.available): + return None + + 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) + + 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, + } + + for item_type, handler in type_handlers.items(): + if isinstance(item, item_type): + return handler(idx, item, explicit, date_user_added, date_release) + return None + + def _get_date_release(self, item: Any) -> str: + """Get the release date string for an item.""" + 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 + ) -> ResultItem: + """Create a ResultItem from a Track.""" + 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}", + album=item.album.name, + duration_sec=item.duration, + obj=item, + quality=final_quality, + explicit=bool(item.explicit), + date_user_added=date_user_added, + date_release=date_release, + ) + + def _result_item_from_video( + self, idx: int, item: Video, explicit: str, date_user_added: str, date_release: str + ) -> ResultItem: + """Create a ResultItem from a Video.""" + return ResultItem( + position=idx, + artist=name_builder_artist(item), + title=f"{item.name}{explicit}", + album=item.album.name if item.album else "", + duration_sec=item.duration, + obj=item, + quality=item.video_quality, + explicit=bool(item.explicit), + date_user_added=date_user_added, + 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.""" + return ResultItem( + position=idx, + artist=", ".join(artist.name for artist in item.promoted_artists) if item.promoted_artists else "", + title=item.name, + album="", + duration_sec=item.duration, + obj=item, + quality="", + explicit=False, + date_user_added=date_user_added, + date_release=date_release, + ) + + def _result_item_from_album( + self, idx: int, item: Album, explicit: str, date_user_added: str, date_release: str + ) -> ResultItem: + """Create a ResultItem from an Album.""" + return ResultItem( + position=idx, + artist=name_builder_artist(item), + title="", + album=f"{item.name}{explicit}", + duration_sec=item.duration, + obj=item, + quality=quality_audio_highest(item), + explicit=bool(item.explicit), + date_user_added=date_user_added, + 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.""" + return ResultItem( + position=idx, + artist=item.sub_title, + title=item.title, + album="", + duration_sec=-1, + obj=item, + quality="", + explicit=False, + date_user_added=date_user_added, + 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.""" + return ResultItem( + position=idx, + artist=item.name, + title="", + album="", + duration_sec=-1, + obj=item, + quality="", + explicit=False, + date_user_added=date_user_added, + 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 + return ResultItem( + position=idx, + artist="", + title=f"📁 {item.name} ({total_items} items)", + album="", + duration_sec=-1, + obj=item, + quality="", + explicit=False, + date_user_added=date_user_added, + date_release=date_release, + ) diff --git a/tidal_dl_ng/helper/hover_manager.py b/tidal_dl_ng/helper/hover_manager.py new file mode 100644 index 0000000..3bda020 --- /dev/null +++ b/tidal_dl_ng/helper/hover_manager.py @@ -0,0 +1,187 @@ +"""Hover Event Manager with Debounce Logic. + +This module provides a robust hover event management system for the track list, +implementing debouncing to prevent UI flickering and excessive updates during +rapid mouse movements. + +The HoverManager intercepts mouse movement events and triggers callbacks only +after the mouse has remained stationary over an item for a configurable delay. + +Architecture: + - HoverManager: Main coordinator with debounce timer + - Event filter for capturing mouse movements + - Signal-based callbacks for decoupled communication + +Performance: + - Configurable debounce delay (default: 350ms) + - Automatic cleanup of pending timers + - Thread-safe signal emissions +""" + +from PySide6 import QtCore, QtGui, QtWidgets +from tidalapi import Album, Mix, Playlist, Track, Video +from tidalapi.artist import Artist + +from tidal_dl_ng.helper.gui import get_results_media_item +from tidal_dl_ng.logger import logger_gui + + +class HoverManager(QtCore.QObject): + """Manages hover events with debouncing to prevent UI flickering. + + This class implements an event filter that intercepts mouse movements over + a QTreeView and emits signals after a configurable delay to prevent rapid + updates when the user is just scanning through the list. + + Signals: + s_hover_confirmed (object): Emitted when hover is confirmed after debounce delay. + s_hover_left: Emitted when mouse leaves the tracked widget. + + Attributes: + debounce_delay_ms (int): Delay in milliseconds before confirming hover. + tree_view (QTreeView): The tree view to monitor. + proxy_model (QSortFilterProxyModel): Proxy model for the tree view. + source_model (QStandardItemModel): Source model for the tree view. + debounce_timer (QTimer): Timer for debouncing hover events. + last_hovered_media (Track | Video | Album | None): Last media item hovered. + """ + + # Signals + s_hover_confirmed: QtCore.Signal = QtCore.Signal(object) # Emits media object + s_hover_left: QtCore.Signal = QtCore.Signal() # Emits when hover leaves + + def __init__( + self, + tree_view: QtWidgets.QTreeView, + proxy_model: QtCore.QSortFilterProxyModel, + source_model: QtGui.QStandardItemModel, + debounce_delay_ms: int = 350, + parent: QtCore.QObject | None = None, + ) -> None: + """Initialize the HoverManager. + + Args: + tree_view (QTreeView): The tree view to monitor for hover events. + proxy_model (QSortFilterProxyModel): Proxy model wrapping the source model. + source_model (QStandardItemModel): Source model containing the data. + debounce_delay_ms (int, optional): Debounce delay in milliseconds. Defaults to 350. + parent (QObject | None, optional): Parent QObject. Defaults to None. + """ + super().__init__(parent) + + self.debounce_delay_ms: int = debounce_delay_ms + self.tree_view: QtWidgets.QTreeView = tree_view + self.proxy_model: QtCore.QSortFilterProxyModel = proxy_model + self.source_model: QtGui.QStandardItemModel = source_model + self.last_hovered_media: Track | Video | Album | Mix | Playlist | Artist | None = None + + # Debounce timer (single-shot) + self.debounce_timer: QtCore.QTimer = QtCore.QTimer(self) + self.debounce_timer.setSingleShot(True) + self.debounce_timer.setInterval(self.debounce_delay_ms) + self.debounce_timer.timeout.connect(self._on_debounce_timeout) + + # Track hover state + self.pending_media: Track | Video | Album | Mix | Playlist | Artist | None = None + + # Install event filter on viewport (where mouse events occur) + self.tree_view.viewport().installEventFilter(self) + self.tree_view.viewport().setMouseTracking(True) + + def stop(self) -> None: + """Stop the hover manager and clean up event filters.""" + try: + if self.tree_view: + self.tree_view.viewport().removeEventFilter(self) + self.tree_view = None + except RuntimeError: + pass + if self.timer and self.timer.isActive(): + self.timer.stop() + + def eventFilter(self, watched: QtCore.QObject, event: QtCore.QEvent) -> bool: + """Filter events to detect mouse movements over tree items. + + Args: + watched (QObject): The object being watched. + event (QEvent): The event to filter. + + Returns: + bool: True if event is handled, False otherwise. + """ + if watched == self.tree_view.viewport(): + if event.type() == QtCore.QEvent.Type.MouseMove: + # Cast to QMouseEvent for type safety + mouse_event = QtGui.QMouseEvent(event) + self._handle_mouse_move(mouse_event) + elif event.type() == QtCore.QEvent.Type.Leave: + self._handle_mouse_leave() + + # Don't consume the event - let it propagate + return False + + def _handle_mouse_move(self, event: QtGui.QMouseEvent) -> None: + """Handle mouse move events over the tree view. + + Args: + event (QMouseEvent): The mouse move event. + """ + # Get the index under the cursor + pos = event.position().toPoint() + index = self.tree_view.indexAt(pos) + + if not index.isValid(): + # Mouse is not over a valid item + self._cancel_pending_hover() + return + + try: + # Extract media from the index + media = get_results_media_item(index, self.proxy_model, self.source_model) + + if not media: + self._cancel_pending_hover() + return + + # Check if we're hovering over a different item + if media != self.pending_media: + # Cancel previous timer and start new one + self.pending_media = media + self.debounce_timer.stop() + self.debounce_timer.start() + + except Exception as e: + logger_gui.debug(f"Error extracting media from hover: {e}") + self._cancel_pending_hover() + + def _handle_mouse_leave(self) -> None: + """Handle mouse leaving the tree view.""" + self._cancel_pending_hover() + self.s_hover_left.emit() + + def _cancel_pending_hover(self) -> None: + """Cancel any pending hover confirmation.""" + self.debounce_timer.stop() + self.pending_media = None + + def _on_debounce_timeout(self) -> None: + """Handle debounce timer timeout - hover is confirmed.""" + if self.pending_media: + self.last_hovered_media = self.pending_media + self.s_hover_confirmed.emit(self.pending_media) + # logger_gui.debug(f"Hover confirmed: {getattr(self.pending_media, 'name', 'Unknown')}") + + def reset(self) -> None: + """Reset the hover manager state.""" + self._cancel_pending_hover() + self.last_hovered_media = None + + def set_debounce_delay(self, delay_ms: int) -> None: + """Change the debounce delay. + + Args: + delay_ms (int): New delay in milliseconds. + """ + self.debounce_delay_ms = delay_ms + self.debounce_timer.setInterval(delay_ms) + logger_gui.debug(f"Hover debounce delay set to {delay_ms}ms") diff --git a/tidal_dl_ng/helper/metadata_utils.py b/tidal_dl_ng/helper/metadata_utils.py new file mode 100644 index 0000000..eac69bd --- /dev/null +++ b/tidal_dl_ng/helper/metadata_utils.py @@ -0,0 +1,227 @@ +"""Utilities for extracting and formatting metadata from TIDAL objects.""" + +from contextlib import suppress + + +def _convert_list_to_str(value: list | tuple) -> str: + """Convert list/tuple to comma-separated string.""" + if not value: + return "—" + with suppress(Exception): + return ", ".join([str(x) for x in value]) + return str(value) + + +def _convert_dict_to_str(value: dict) -> str: + """Extract meaningful string from dict.""" + if "name" in value and value["name"]: + return str(value["name"]) + for k in ("label", "title", "genre", "name"): + if k in value and value[k]: + return str(value[k]) + with suppress(Exception): + vals = [str(v) for v in value.values() if v is not None] + if vals: + return ", ".join(vals) + return str(value) + + +def safe_str(value: object) -> str: + """Convert a potentially non-str value into a safe string for display. + + Args: + value: The value to convert. + + Returns: + str: Safe string representation, '—' for None/empty. + """ + with suppress(Exception): + if value is None: + return "—" + if isinstance(value, str): + return value if value != "" else "—" + if isinstance(value, list | tuple): + return _convert_list_to_str(value) + if isinstance(value, dict): + return _convert_dict_to_str(value) + return str(value) + return "—" + + +def _find_in_dict_container(container: dict, names: tuple[str, ...]) -> object | None: + """Search for names in a dict container.""" + for n in names: + if n in container and container[n] is not None: + return container[n] + # Fuzzy key match + keys = list(container.keys()) + for n in names: + for k in keys: + if n.lower() in str(k).lower(): + return container[k] + return None + + +def _fuzzy_scan_attrs(obj: object, names: tuple[str, ...]) -> object | None: + """Fuzzy scan object attributes for matching names.""" + with suppress(Exception): + for k in dir(obj): + kl = k.lower() + for n in names: + if n.lower() in kl: + with suppress(Exception): + val = getattr(obj, k) + if val is not None: + return val + return None + + +def find_attr(obj: object, *names: str) -> object | None: + """Attempt to find an attribute or data key from an object or its internals. + + Args: + obj: The object to inspect. + *names: Attribute/key names to search for. + + Returns: + The found value or None. + """ + # Direct attributes + for n in names: + with suppress(Exception): + if hasattr(obj, n): + val = getattr(obj, n) + if val is not None: + return val + + # Inspect common dict-like internals + for container_name in ("_data", "data", "__dict__"): + with suppress(Exception): + container = getattr(obj, container_name, None) + if isinstance(container, dict): + result = _find_in_dict_container(container, names) + if result is not None: + return result + + # Fuzzy scan of attributes + return _fuzzy_scan_attrs(obj, names) + + +def _scan_dict_recursive(container: dict, key_substrings: list[str]) -> object | None: + """Recursively scan a dict for keys matching any substring.""" + for k, v in container.items(): + kl = str(k).lower() + for s in key_substrings: + if s.lower() in kl and v is not None: + return v + # recurse into nested structures + if isinstance(v, dict): + found = _scan_dict_recursive(v, key_substrings) + if found is not None: + return found + if isinstance(v, list | tuple): + for item in v: + if isinstance(item, dict): + found = _scan_dict_recursive(item, key_substrings) + if found is not None: + return found + return None + + +def search_in_data(obj: object, key_substrings: list[str]) -> object | None: + """Recursively search dict-like internals for keys containing any substring. + + Args: + obj: The object to search. + key_substrings: List of key substrings to search for. + + Returns: + The first matching value found, or None. + """ + # check common containers + for container_name in ("_data", "data", "__dict__"): + with suppress(Exception): + container = getattr(obj, container_name, None) + if isinstance(container, dict): + found = _scan_dict_recursive(container, key_substrings) + if found is not None: + return found + + # as last resort, try obj.__dict__ if available + with suppress(Exception): + d = getattr(obj, "__dict__", None) + if isinstance(d, dict): + return _scan_dict_recursive(d, key_substrings) + + return None + + +def _extract_name_from_dict(item: dict, match_types: tuple[str, ...] | None) -> str | None: + """Extract name from a dict if it matches type filters.""" + if "name" not in item or not item["name"]: + return None + if match_types: + t = item.get("type") or item.get("role") or item.get("credit_type") + if t and any(mt in str(t).lower() for mt in match_types): + return str(item["name"]) + return None + return str(item["name"]) + + +def _extract_name_from_item(item: object) -> str | None: + """Extract name from various item formats.""" + if item is None: + return None + if isinstance(item, str): + return item + if isinstance(item, dict): + # Try common name keys + for k in ("name", "artist", "person"): + if k in item and item[k]: + return str(item[k]) + return None + # Try object attributes + nm = getattr(item, "name", None) or getattr(item, "title", None) + if nm: + return str(nm) + with suppress(Exception): + return str(item) + return None + + +def extract_names_from_mixed(value: object, match_types: tuple[str, ...] | None = None) -> list[str]: + """Normalize various credit-like structures into a list of names. + + Accepts lists of dicts, dicts, strings, or objects. + If match_types provided, only include entries where the type/role matches one of them. + + Args: + value: The value to extract names from. + match_types: Optional tuple of role types to filter by. + + Returns: + List of extracted names. + """ + if value is None: + return [] + if isinstance(value, str): + return [value] + if isinstance(value, dict): + name = _extract_name_from_dict(value, match_types) + if name: + return [name] + vals = [str(v) for v in value.values() if v is not None] + return vals + if isinstance(value, list | tuple): + names: list[str] = [] + for item in value: + if isinstance(item, dict): + name = _extract_name_from_dict(item, match_types) + if name: + names.append(name) + else: + name = _extract_name_from_item(item) + if name: + names.append(name) + return names + return [] diff --git a/tidal_dl_ng/helper/tidal.py b/tidal_dl_ng/helper/tidal.py index 586e196..22ae8b0 100644 --- a/tidal_dl_ng/helper/tidal.py +++ b/tidal_dl_ng/helper/tidal.py @@ -1,5 +1,8 @@ +import contextlib +import os from collections.abc import Callable +import requests from tidalapi import Album, Mix, Playlist, Session, Track, UserPlaylist, Video from tidalapi.artist import Artist, Role from tidalapi.media import MediaMetadataTags, Quality @@ -135,7 +138,7 @@ def search_results_all(session: Session, needle: str, types_media: SearchTypes = def items_results_all( - media_list: [Mix | Playlist | Album | Artist], videos_include: bool = True + session: Session, media_list: [Mix | Playlist | Album | Artist], videos_include: bool = True ) -> [Track | Video | Album]: result: [Track | Video | Album] = [] @@ -149,7 +152,7 @@ def items_results_all( func_get_items_media.append(media_list.items) else: func_get_items_media.append(media_list.tracks) - else: + elif isinstance(media_list, Artist): func_get_items_media.append(media_list.get_albums) func_get_items_media.append(media_list.get_ep_singles) @@ -257,12 +260,20 @@ def instantiate_media( def quality_audio_highest(media: Track | Album) -> Quality: quality: Quality - if MediaMetadataTags.hi_res_lossless in media.media_metadata_tags: + # media_metadata_tags may be missing (Mock objects) or None; use safe getter + tags = getattr(media, "media_metadata_tags", None) + try: + iterable_tags = set(tags) if tags is not None else set() + except Exception: + # If tags is a Mock or non-iterable, fall back to empty set + iterable_tags = set() + + if MediaMetadataTags.hi_res_lossless in iterable_tags: quality = Quality.hi_res_lossless - elif MediaMetadataTags.lossless in media.media_metadata_tags: + elif MediaMetadataTags.lossless in iterable_tags: quality = Quality.high_lossless else: - quality = media.audio_quality + quality = getattr(media, "audio_quality", Quality.low_320k) return quality @@ -272,3 +283,322 @@ def favorite_function_factory(tidal, favorite_item: str): function_list: Callable = getattr(tidal.session.user.favorites, function_name) return function_list + + +def fetch_raw_media_json( + session: Session, media_type: str, media_id: str, country_code: str | None = None, extra_params: dict | None = None +) -> dict | None: + """Fetch raw JSON for a media resource using tidalapi's session.request. + + Args: + session (Session): the tidalapi Session + media_type (str): 'tracks' or 'albums' + media_id (str): id of media + country_code (str | None): optional countryCode param + + Returns: + dict | None: parsed JSON or None if fetch fails + """ + try: + params = {} + # If caller didn't provide a country code, check environment variable + cc = country_code or os.environ.get("TIDAL_COUNTRY") + if cc: + params["countryCode"] = cc + # merge extra params if provided (do not overwrite existing keys unless provided) + if extra_params and isinstance(extra_params, dict): + for k, v in extra_params.items(): + params[k] = v + + # Use session.request.request to call the internal API endpoint + resp = session.request.request("GET", f"{media_type}/{media_id}", params=params) + resp.raise_for_status() + return resp.json() + except requests.exceptions.HTTPError: + return None # Silently ignore HTTP errors + except Exception: + return None # Silently ignore other errors + + +def fetch_raw_track_and_album( + session: Session, + track_id: str, + country_code: str | None = None, + extra_params: dict | None = None, +) -> tuple[dict | None, dict | None]: + """Convenience to fetch raw track JSON and its album JSON (if available). + + Returns a tuple (track_json, album_json). + + Uses TIDAL API parameters to fetch extended metadata. + Note: 'credits' and 'contributors' are NOT available in TIDAL API v2. + Available include values: albums, artists, genres, lyrics, owners, providers, + radio, shares, similarTracks, sourceFile, trackStatistics + """ + # Use valid include parameters according to TIDAL API v2 spec + # https://tidal-music.github.io/tidal-api-reference/ + default_track_params = { + "include": "albums,artists,genres", # Valid parameters per API spec + } + merged_track_params = {**default_track_params, **(extra_params or {})} + + track_json = fetch_raw_media_json( + session, + "tracks", + str(track_id), + country_code=country_code, + extra_params=merged_track_params, + ) + + album_json = None + try: + if isinstance(track_json, dict): + album = track_json.get("album") + album_id = album.get("id") if isinstance(album, dict) else album + if album_id: + # Request extended album metadata + default_album_params = { + "include": "artists,genres", # Valid parameters for albums + } + merged_album_params = {**default_album_params, **(extra_params or {})} + + album_json = fetch_raw_media_json( + session, + "albums", + str(album_id), + country_code=country_code, + extra_params=merged_album_params, + ) + except Exception: + album_json = None + + return track_json, album_json + + +def _normalize_dict_contributors(raw_contributors: dict) -> dict[str, list[str]]: + """Process contributors in dict format: role -> list[{name, ...}].""" + result: dict[str, list[str]] = {} + for role, people in raw_contributors.items(): + if not isinstance(people, list): + continue + names: list[str] = [] + for person in people: + if isinstance(person, dict): + name = person.get("name") + if isinstance(name, str) and name: + names.append(name) + if names: + result[role] = names + return result + + +def _normalize_list_contributors(raw_contributors: list) -> dict[str, list[str]]: + """Process contributors in list format: [{name, role, ...}, ...].""" + result: dict[str, list[str]] = {} + for person in raw_contributors: + if not isinstance(person, dict): + continue + name = person.get("name") + role = person.get("role") + if isinstance(name, str) and name and isinstance(role, str) and role: + result.setdefault(role, []).append(name) + return result + + +def _normalize_contributors(raw_contributors: object) -> dict[str, list[str]]: + """Normalize various possible contributor JSON shapes into role -> list[str] names. + + The TIDAL API has used at least two shapes historically: + - dict role -> list[ {"name": str, ...} ] + - list[ {"name": str, "role": str, ...} ] + + We accept both and ignore malformed entries. + """ + if isinstance(raw_contributors, dict): + return _normalize_dict_contributors(raw_contributors) + if isinstance(raw_contributors, list): + return _normalize_list_contributors(raw_contributors) + return {} + + +def _extract_bpm_from_track(track_json: dict) -> int | None: + """Extract BPM from track JSON.""" + bpm = track_json.get("bpm") + if isinstance(bpm, int | float): + return int(round(bpm)) + if isinstance(bpm, str): + with contextlib.suppress(ValueError): + return int(round(float(bpm))) + return None + + +def _process_credits_contributors(credits_list: list) -> dict[str, list[str]]: + """Process credits API v2 format and return contributors by role.""" + role_mapping = { + "producer": "producer", + "producers": "producer", + "composer": "composer", + "composers": "composer", + "lyricist": "lyricist", + "lyricists": "lyricist", + "writer": "composer", + "writers": "composer", + } + result: dict[str, list[str]] = {} + for credit in credits_list: + if not isinstance(credit, dict): + continue + credit_type = credit.get("type", "").lower() + contributors = credit.get("contributors", []) + role = role_mapping.get(credit_type, credit_type) + if isinstance(contributors, list): + for contributor in contributors: + if isinstance(contributor, dict): + name = contributor.get("name") + if name: + result.setdefault(role, []).append(name) + return result + + +def _extract_track_contributors(track_json: dict) -> dict[str, list[str]]: + """Extract contributors from track JSON.""" + # Try credits first (API v2) + track_credits = track_json.get("credits") + if track_credits and isinstance(track_credits, list): + contributors = _process_credits_contributors(track_credits) + if contributors: + return contributors + # Fallback to old format + raw_contributors = track_json.get("contributors") + if raw_contributors: + return _normalize_contributors(raw_contributors) + return {} + + +def _process_genre_item(g: object) -> str | None: + """Extract genre name from various formats.""" + if isinstance(g, str) and g: + return g + if isinstance(g, dict): + name = g.get("name") + if isinstance(name, str) and name: + return name + return None + + +def _deduplicate_genres(genres: list[str]) -> list[str]: + """Deduplicate genres while preserving order.""" + seen: set[str] = set() + unique: list[str] = [] + for g in genres: + if g not in seen: + seen.add(g) + unique.append(g) + return unique + + +def _extract_album_label_genres(album_json: dict) -> tuple[str, list[str]]: + """Extract label and genres from album JSON.""" + # Label + label = album_json.get("label") or album_json.get("recordLabel") + label_str = label if isinstance(label, str) else "" + + # Genres + raw_genres = album_json.get("genres") or album_json.get("genre") + genres: list[str] = [] + + if isinstance(raw_genres, list): + for g in raw_genres: + genre = _process_genre_item(g) + if genre: + genres.append(genre) + elif isinstance(raw_genres, str) and raw_genres: + genres.append(raw_genres) + else: + genre = _process_genre_item(raw_genres) + if genre: + genres.append(genre) + + # Deduplicate while preserving order + if genres: + return label_str, _deduplicate_genres(genres) + return label_str, [] + + +def _extract_album_contributors(album_json: dict) -> dict[str, list[str]]: + """Extract contributors from album JSON.""" + # Try credits first (API v2) + album_credits = album_json.get("credits") + if album_credits and isinstance(album_credits, list): + contributors = _process_credits_contributors(album_credits) + if contributors: + return contributors + # Fallback to old format + raw_contributors = album_json.get("contributors") + if raw_contributors: + return _normalize_contributors(raw_contributors) + return {} + + +def parse_track_and_album_extras( + track_json: dict | None, + album_json: dict | None, +) -> dict: + """Extract extra metadata from raw TIDAL JSON for a track and its album. + + Returned dict keys (all optional, may be missing or empty): + - bpm: int | None + - label: str + - genres: list[str] + - contributors_by_role: dict[str, list[str]] + """ + + extras: dict = { + "bpm": None, + "label": "", + "genres": [], + "contributors_by_role": {}, + } + + # Extract from track + if isinstance(track_json, dict): + extras["bpm"] = _extract_bpm_from_track(track_json) + extras["contributors_by_role"] = _extract_track_contributors(track_json) + + # Extract from album + if isinstance(album_json, dict): + label, genres = _extract_album_label_genres(album_json) + extras["label"] = label + extras["genres"] = genres + + # If we did not get track-level contributors, try album-level + if not extras["contributors_by_role"]: + extras["contributors_by_role"] = _extract_album_contributors(album_json) + + return extras + + +def extract_contributor_names( + contributors_by_role: dict[str, list[str]] | None, + role: str, + delimiter: str = ", ", +) -> str: + """Return a delimited string of contributor names for a given role. + + If the role is not present or has no names, returns an empty string. + Role matching is case-insensitive. + """ + if not contributors_by_role: + return "" + + # Normalise keys to lowercase for robust lookups. + role_lc = role.lower() + for r, names in contributors_by_role.items(): + if not isinstance(r, str): + continue + if r.lower() == role_lc and isinstance(names, list): + filtered = [n for n in names if isinstance(n, str) and n] + if filtered: + return delimiter.join(filtered) + + return "" diff --git a/tidal_dl_ng/metadata.py b/tidal_dl_ng/metadata.py index 7d8ccb9..4ee3452 100644 --- a/tidal_dl_ng/metadata.py +++ b/tidal_dl_ng/metadata.py @@ -32,6 +32,13 @@ class Metadata: upc: str target_upc: dict[str, str] explicit: bool + # New enriched metadata fields + genre: str + label: str + bpm: int | None + producers: str + composers_detailed: str + lyricists: str m: mutagen.mp4.MP4 | mutagen.mp4.MP4 | mutagen.flac.FLAC def __init__( @@ -61,6 +68,13 @@ class Metadata: replay_gain_write: bool = True, upc: str = "", explicit: bool = False, + # New enriched metadata kwargs (all optional) + genre: str = "", + label: str = "", + bpm: int | None = None, + producers: str = "", + composers_detailed: str = "", + lyricists: str = "", ): self.path_file = path_file self.title = title @@ -87,6 +101,13 @@ class Metadata: self.upc = upc self.target_upc = target_upc self.explicit = explicit + # Store enriched metadata + self.genre = genre + self.label = label + self.bpm = bpm + self.producers = producers + self.composers_detailed = composers_detailed + self.lyricists = lyricists self.m: mutagen.FileType = mutagen.File(self.path_file) def _cover(self) -> bool: @@ -139,12 +160,24 @@ class Metadata: self.m.tags["DISCNUMBER"] = str(self.discnumber) self.m.tags["DISCTOTAL"] = str(self.totaldisc) self.m.tags["DATE"] = self.date - self.m.tags["COMPOSER"] = self.composer + self.m.tags["COMPOSER"] = self.composer if self.composer else self.composers_detailed self.m.tags["ISRC"] = self.isrc self.m.tags["LYRICS"] = self.lyrics self.m.tags["UNSYNCEDLYRICS"] = self.lyrics_unsynced self.m.tags["URL"] = self.url_share self.m.tags[self.target_upc["FLAC"]] = self.upc + # Enriched fields + if self.genre: + self.m.tags["GENRE"] = self.genre + if self.label: + # Using LABEL; some tools map PUBLISHER, but LABEL is widely recognised in Vorbis. + self.m.tags["LABEL"] = self.label + if self.bpm is not None: + self.m.tags["BPM"] = str(self.bpm) + if self.producers: + self.m.tags["PRODUCER"] = self.producers + if self.lyricists: + self.m.tags["LYRICIST"] = self.lyricists if self.replay_gain_write: self.m.tags["REPLAYGAIN_ALBUM_GAIN"] = str(self.album_replay_gain) @@ -163,12 +196,29 @@ class Metadata: self.m.tags.add(TRCK(encoding=3, text=str(self.tracknumber))) self.m.tags.add(TRCK(encoding=3, text=self.discnumber)) self.m.tags.add(TDRC(encoding=3, text=self.date)) - self.m.tags.add(TCOM(encoding=3, text=self.composer)) + self.m.tags.add(TCOM(encoding=3, text=self.composer if self.composer else self.composers_detailed)) self.m.tags.add(TSRC(encoding=3, text=self.isrc)) self.m.tags.add(SYLT(encoding=3, desc="text", text=self.lyrics)) self.m.tags.add(USLT(encoding=3, desc="text", text=self.lyrics_unsynced)) self.m.tags.add(WOAS(encoding=3, text=self.isrc)) self.m.tags.add(TXXX(encoding=3, desc=self.target_upc["MP3"], text=self.upc)) + # Enriched fields + if self.genre: + from mutagen.id3 import TCON + + self.m.tags.add(TCON(encoding=3, text=self.genre)) + if self.label: + from mutagen.id3 import TPUB + + self.m.tags.add(TPUB(encoding=3, text=self.label)) + if self.bpm is not None: + from mutagen.id3 import TBPM + + self.m.tags.add(TBPM(encoding=3, text=str(self.bpm))) + if self.producers: + self.m.tags.add(TXXX(encoding=3, desc="PRODUCER", text=self.producers)) + if self.lyricists: + self.m.tags.add(TXXX(encoding=3, desc="LYRICIST", text=self.lyricists)) if self.replay_gain_write: self.m.tags.add(TXXX(encoding=3, desc="REPLAYGAIN_ALBUM_GAIN", text=str(self.album_replay_gain))) @@ -184,15 +234,26 @@ class Metadata: self.m.tags["cprt"] = self.copy_right self.m.tags["trkn"] = [[self.tracknumber, self.totaltrack]] self.m.tags["disk"] = [[self.discnumber, self.totaldisc]] - # self.m.tags['\xa9gen'] = self.genre + if self.genre: + self.m.tags["\xa9gen"] = self.genre self.m.tags["\xa9day"] = self.date - self.m.tags["\xa9wrt"] = self.composer + self.m.tags["\xa9wrt"] = self.composer if self.composer else self.composers_detailed self.m.tags["\xa9lyr"] = self.lyrics self.m.tags["----:com.apple.iTunes:UNSYNCEDLYRICS"] = self.lyrics_unsynced.encode("utf-8") self.m.tags["isrc"] = self.isrc self.m.tags["\xa9url"] = self.url_share self.m.tags[f"----:com.apple.iTunes:{self.target_upc['MP4']}"] = self.upc.encode("utf-8") self.m.tags["rtng"] = [1 if self.explicit else 0] + # Custom iTunes free-form tags for label / credits + if self.label: + self.m.tags["----:com.apple.iTunes:LABEL"] = self.label.encode("utf-8") + if self.producers: + self.m.tags["----:com.apple.iTunes:PRODUCER"] = self.producers.encode("utf-8") + if self.lyricists: + self.m.tags["----:com.apple.iTunes:LYRICIST"] = self.lyricists.encode("utf-8") + if self.bpm is not None: + # Standard MP4 tempo atom + self.m.tags["tmpo"] = [int(self.bpm)] if self.replay_gain_write: self.m.tags["----:com.apple.iTunes:REPLAYGAIN_ALBUM_GAIN"] = str(self.album_replay_gain).encode("utf-8") diff --git a/tidal_dl_ng/ui/info_tab_widget.py b/tidal_dl_ng/ui/info_tab_widget.py new file mode 100644 index 0000000..05e0d6f --- /dev/null +++ b/tidal_dl_ng/ui/info_tab_widget.py @@ -0,0 +1,883 @@ +"""Info Tab Widget - Dynamic tabbed information panel for track hover preview. + +This module provides a sophisticated tabbed widget that displays rich metadata +for TIDAL media items on hover, implementing a debounced event system to prevent +UI flickering and unnecessary API calls. + +Architecture: + - InfoTabWidget: Main container with two tabs (Details + Cover Art) + - DebounceTimer: Configurable timer to prevent event flooding + - TrackInfoFormatter: Utility class to format track metadata consistently + +Performance Considerations: + - Pre-loaded metadata to avoid API chattiness + - Debounced hover events (300-400ms delay) + - Thread-safe signal emissions for cross-thread updates +""" + +import datetime +from collections.abc import Callable, Iterable +from contextlib import suppress +from typing import TYPE_CHECKING + +from PySide6 import QtCore, QtGui, QtWidgets +from tidalapi import Album, Mix, Playlist, Track, Video +from tidalapi.artist import Artist +from tidalapi.media import AudioMode + +from tidal_dl_ng.helper.metadata_utils import extract_names_from_mixed, find_attr, safe_str +from tidal_dl_ng.helper.path import resource_path +from tidal_dl_ng.helper.tidal import name_builder_artist, name_builder_title, quality_audio_highest + +if TYPE_CHECKING: + pass + + +class TrackInfoFormatter: + """Formats track metadata into human-readable strings. + + This utility class handles the conversion of raw TIDAL API objects into + formatted display strings for the UI. + """ + + @staticmethod + def _get_artist_name(artist: object) -> str: + """Safely get artist name from artist object.""" + name = getattr(artist, "name", None) + if name is None: + with suppress(Exception): + return str(artist) + return "Unknown" + return str(name) + + @staticmethod + def _format_artist_roles(artist: object) -> list[str]: + """Extract and format artist roles.""" + if not hasattr(artist, "roles") or not artist.roles: + return [] + roles = [] + for role in artist.roles: + rname = getattr(role, "name", None) + if rname is None: + with suppress(Exception): + rname = str(role) + if rname: + roles.append(str(rname)) + return roles + + @staticmethod + def format_artists(media: Track | Video | Album) -> str: + """Format artist names with roles if available. + + Args: + media (Track | Video | Album): The media object with artist information. + + Returns: + str: Formatted artist string (e.g., "Artist1, Artist2 (Feature)"). + """ + with suppress(Exception): + artist_parts: list[str] = [] + artists = getattr(media, "artists", []) + + for artist in artists: + name = TrackInfoFormatter._get_artist_name(artist) + roles = TrackInfoFormatter._format_artist_roles(artist) + + if roles and roles[0] != "main": + name = f"{name} ({', '.join(roles)})" + artist_parts.append(name) + + if artist_parts: + return ", ".join(artist_parts) + + return name_builder_artist(media) if hasattr(media, "artists") else "Unknown Artist" + + @staticmethod + def format_codec(track: Track) -> str: + """Format codec information including audio mode. + + Args: + track (Track): The track object. + + Returns: + str: Formatted codec string (e.g., "FLAC / Dolby Atmos"). + """ + codec_parts: list[str] = [] + + # If this is a Video object, prefer video_quality (e.g., '1080p') + if hasattr(track, "video_quality"): + with suppress(Exception): + return str(track.video_quality) + + # Get base quality for audio tracks + quality = quality_audio_highest(track) + if quality: + codec_parts.append(str(quality).upper()) + + # Check for Dolby Atmos + if hasattr(track, "audio_modes") and track.audio_modes and AudioMode.dolby_atmos.value in track.audio_modes: + codec_parts.append("Dolby Atmos") + + # Check for MQA + if ( + hasattr(track, "media_metadata_tags") + and track.media_metadata_tags + and "MQA" in str(track.media_metadata_tags) + ): + codec_parts.append("MQA") + + return " / ".join(codec_parts) if codec_parts else "N/A" + + @staticmethod + def format_duration(duration_sec: int | None) -> str: + """Format duration in seconds to MM:SS format. + + Args: + duration_sec (int | None): Duration in seconds. + + Returns: + str: Formatted duration string. + """ + # Treat 0 as a valid duration (00:00); only None or missing -> N/A + if duration_sec is None: + return "N/A" + + minutes = duration_sec // 60 + seconds = duration_sec % 60 + return f"{minutes:02d}:{seconds:02d}" + + @staticmethod + def format_date(date_obj) -> str: + """Format date object to readable string. + + Args: + date_obj: Date object (datetime or similar). + + Returns: + str: Formatted date string (YYYY-MM-DD) or N/A. + """ + if date_obj is None: + return "N/A" + + try: + # Only call strftime on actual date/datetime objects to avoid + # mocks or unrelated objects having a 'strftime' attribute. + if isinstance(date_obj, datetime.date | datetime.datetime): + return date_obj.strftime("%Y-%m-%d") + + # If it's already a string, return as-is + if isinstance(date_obj, str): + return date_obj + + # Fallback to string conversion + return str(date_obj) + except Exception: + return "N/A" + + @staticmethod + def format_bitrate(track: Track) -> str: + """Format bitrate information. + + Args: + track (Track): The track object. + + Returns: + str: Formatted bitrate string. + """ + # Check for bit depth and sample rate + bit_depth = getattr(track, "bit_depth", None) + sample_rate = getattr(track, "sample_rate", None) + + if bit_depth and sample_rate: + return f"{bit_depth}-bit / {sample_rate / 1000:.1f} kHz" + elif sample_rate: + return f"{sample_rate / 1000:.1f} kHz" + return "N/A" + + +class InfoTabWidget(QtCore.QObject): + """Controller for the tabbed widget displaying rich media information. + + This class manages the logic for the info tabs but is not a widget itself. + It binds to existing widgets created from a .ui file. + """ + + # Signals for cross-thread communication + s_update_details: QtCore.Signal = QtCore.Signal(object) + s_update_cover: QtCore.Signal = QtCore.Signal(str) + s_spinner_start: QtCore.Signal = QtCore.Signal() + s_spinner_stop: QtCore.Signal = QtCore.Signal() + s_search_in_app: QtCore.Signal = QtCore.Signal(str, str) + s_search_in_browser: QtCore.Signal = QtCore.Signal(str, str) + + def _setup_search_roots(self, parent_widget: QtWidgets.QTabWidget | None) -> list: + """Setup list of widget roots to search for UI elements.""" + search_roots = [parent_widget] + if hasattr(parent_widget, "parent") and parent_widget.parent() is not None: + search_roots.append(parent_widget.parent()) + with suppress(Exception): + root_window = parent_widget.window() + if root_window is not None: + search_roots.append(root_window) + return search_roots + + def _find_widget_in_roots(self, objname: str, search_roots: list) -> QtWidgets.QLabel | None: + """Find a QLabel widget by objectName in search roots.""" + for root in search_roots: + with suppress(Exception): + found = root.findChild(QtWidgets.QLabel, objname) + if found is not None: + return found + return None + + def _create_fallback_widget(self, desc: str, parent_widget: QtWidgets.QWidget) -> tuple[str, QtWidgets.QLabel]: + """Create a fallback invisible widget for a missing UI element.""" + name = desc.split("(")[-1].rstrip(")") if "(" in desc and ")" in desc else f"fallback_{desc.replace(' ', '_')}" + + fallback = QtWidgets.QLabel("—", parent_widget) + fallback.setObjectName(name) + fallback.setVisible(False) + return name, fallback + + def __init__( + self, + parent: QtWidgets.QWidget | None = None, + existing_tab_widget: QtWidgets.QTabWidget | None = None, + ) -> None: + """Initialize the InfoTabWidget controller. + + Args: + parent (QWidget | None): Parent widget. Defaults to None. + existing_tab_widget (QTabWidget | None): The tab widget from the UI to control. + """ + super().__init__(parent) + + # State management + self.current_media_hovered: Track | Video | Album | Mix | Playlist | Artist | None = None + self.current_media_selected: Track | Video | Album | Mix | Playlist | Artist | None = None + self.cover_url_current: str = "" + self._track_extras_provider: Callable[[str], dict] | None = None + + # Bind to widgets created in the .ui by exact objectName. + self.tab_widget = existing_tab_widget + parent_widget = self.tab_widget + + # REQUIRED object names provided by main.ui + required = { + "lbl_title": "Title label (lbl_title)", + "lbl_version": "Version label (lbl_version)", + "lbl_artists": "Artists label (lbl_artists)", + "lbl_album": "Album label (lbl_album)", + "lbl_codec": "Codec label (lbl_codec)", + "lbl_bitrate": "Bitrate label (lbl_bitrate)", + "lbl_duration": "Duration label (lbl_duration)", + "lbl_release_date": "Release date label (lbl_release_date)", + "lbl_popularity": "Popularity label (lbl_popularity)", + "lbl_bpm": "BPM label (lbl_bpm)", + "lbl_isrc": "ISRC label (lbl_isrc)", + "lbl_track_number": "Track number label (lbl_track_number)", + "l_pm_cover": "Cover QLabel (l_pm_cover)", + } + + # Lookup and assign + search_roots = self._setup_search_roots(parent_widget) + missing = [] + + for objname, desc in required.items(): + found_widget = self._find_widget_in_roots(objname, search_roots) + if not found_widget: + missing.append(desc) + else: + attr_name = "cover_label" if objname == "l_pm_cover" else objname + setattr(self, attr_name, found_widget) + + # Create fallback widgets for missing elements + if missing: + for desc in missing: + name, fallback = self._create_fallback_widget(desc, parent_widget) + if name == "l_pm_cover": + self.cover_label = fallback + else: + setattr(self, name, fallback) + + # Ensure cover_label exists + if not hasattr(self, "cover_label"): + self.cover_label = QtWidgets.QLabel("", parent_widget) + self.cover_label.setVisible(False) + self.cover_label.setObjectName("cover_label_fallback") + + # connect signals + self._connect_signals() + + # Make labels clickable using linkActivated signal + clickable_labels = [self.lbl_title, self.lbl_artists, self.lbl_album] + for label in clickable_labels: + label.setTextFormat(QtCore.Qt.RichText) + label.setOpenExternalLinks(False) # We handle links manually + label.linkActivated.connect(self._on_link_activated) + label.installEventFilter(self) # For right-click handling + label.setToolTip("Left-click to search in app, Ctrl+click or right-click to search in browser.") + + def eventFilter(self, watched: QtCore.QObject, event: QtCore.QEvent) -> bool: + """Filter events for clickable labels to handle right-clicks.""" + if event.type() == QtCore.QEvent.Type.MouseButtonPress and isinstance(watched, QtWidgets.QLabel): + event: QtGui.QMouseEvent + if event.button() == QtCore.Qt.MouseButton.RightButton: + # On right-click, find the link under the cursor + anchor = watched.anchorAt(event.pos()) + if anchor: + self._on_link_activated(anchor, right_click=True) + return True # Event handled + + return super().eventFilter(watched, event) + + def _create_link(self, search_type: str, text: str) -> str: + """Create an HTML link for search, using a specific color.""" + if not text or text == "—": + return "—" + # The link format is "search:type:text" + link_target = f"search:{search_type}:{text}" + # Use a standard link color; Qt will handle styling. + return f"<a href='{link_target}' style='color:#367af6;'>{text}</a>" + + @QtCore.Slot(str) + def _on_link_activated(self, link: str, right_click: bool = False) -> None: + """Handle clicks on rich text links in labels.""" + if not link: + return + + parts = link.split(":", 2) + if len(parts) < 3 or parts[0] != "search": + return + + search_type = parts[1] + search_term = parts[2] + modifiers = QtWidgets.QApplication.keyboardModifiers() + + is_browser_action = right_click or (modifiers & QtCore.Qt.KeyboardModifier.ControlModifier) + + if is_browser_action: + # Ctrl+Click or Right-Click: Open in browser + self.s_search_in_browser.emit(search_term, search_type) + else: + # Left-Click: Search in app + self.s_search_in_app.emit(search_term, search_type) + + def _connect_signals(self) -> None: + """Connect internal signals to slots.""" + self.s_update_details.connect(self._on_update_details) + self.s_update_cover.connect(self._on_update_cover) + self.s_spinner_start.connect(self._spin_start) + self.s_spinner_stop.connect(self._spin_stop) + + def _on_update_cover(self, cover_url: str) -> None: + """Update cover image from URL. + + Args: + cover_url (str): URL or path to the cover image. + """ + if cover_url and cover_url != self.cover_url_current: + self.cover_url_current = cover_url + with suppress(Exception): + self.set_cover_pixmap(cover_url) + + def set_track_extras_provider( + self, provider: Callable[[str, Callable[[str, dict | None], None]], dict | None] | None + ) -> None: + """Register a callable that fetches track extras asynchronously. + + The provider must accept ``track_id`` and a callback; it may return + immediate extras synchronously. Passing ``None`` removes the provider. + """ + self._track_extras_provider = provider + + def set_cover_pixmap(self, content: QtGui.QPixmap | str) -> None: + """Set the cover image pixmap or path.""" + with suppress(Exception): + if isinstance(content, QtGui.QPixmap): + self.cover_label.setPixmap(content) + elif isinstance(content, str) and content: + pixmap = QtGui.QPixmap() + if pixmap.load(content): + self.cover_label.setPixmap(pixmap) + + def _spin_start(self) -> None: + """Track nested spinner requests so UI stays responsive.""" + count = getattr(self, "_spinner_depth", 0) + 1 + self._spinner_depth = count + parent = self.parent() + if count == 1 and parent and isinstance(parent, QtWidgets.QWidget): + parent.setCursor(QtCore.Qt.CursorShape.BusyCursor) + + def _spin_stop(self) -> None: + """Release spinner state when last request finishes.""" + count = max(getattr(self, "_spinner_depth", 1) - 1, 0) + self._spinner_depth = count + parent = self.parent() + if count == 0 and parent and isinstance(parent, QtWidgets.QWidget): + parent.setCursor(QtCore.Qt.CursorShape.ArrowCursor) + + def _request_track_extras_if_needed(self, media: object) -> None: + track_id = getattr(media, "id", None) + if not isinstance(track_id, int | str): + return + if not isinstance(media, Track): + return + if self._track_extras_provider is None: + return + self.s_spinner_start.emit() + try: + immediate = self._track_extras_provider(str(track_id), self._handle_track_extras_ready) + if immediate: + self._apply_track_extras(immediate) + self.s_spinner_stop.emit() + except Exception: + self.s_spinner_stop.emit() + + def _handle_track_extras_ready(self, track_id: str, extras: dict | None) -> None: + """Callback invoked by the provider when async extras are ready.""" + try: + if extras: + current = self.current_media_hovered or self.current_media_selected + current_id = getattr(current, "id", None) + if current_id is not None and str(current_id) == str(track_id): + self._apply_track_extras(extras) + finally: + self.s_spinner_stop.emit() + + def _apply_track_extras(self, extras: dict | None) -> None: + """Apply the track extras to the UI. + + This method updates the details tab with additional information such as + BPM if available. + + Note: Only BPM is available from TIDAL API extras. Genres, label, producers + and composers are not reliably available. + + Args: + extras (dict | None): A dictionary containing extra information + about the track, or None if no extras are available. + """ + if not isinstance(extras, dict): + return + with suppress(RuntimeError): + self._update_extras_ui(extras) + + @QtCore.Slot(dict) + def _update_extras_ui(self, extras: dict): + """Safely update UI with extras from the main thread.""" + if not isinstance(extras, dict): + return + try: + bpm_value = extras.get("bpm") + display_value = "—" if bpm_value is None else str(bpm_value) + self.lbl_bpm.setText(display_value) + except RuntimeError: + pass # Widgets deleted, silently ignore + + def update_on_hover(self, media: Track | Video | Album | Mix | Playlist | Artist | None) -> None: + """Update display based on hovered media item. + + This method is called when the user hovers over a track in the results list. + It displays information from pre-loaded metadata without triggering API calls. + + Args: + media (Track | Video | Album | Mix | Playlist | Artist | None): The hovered media item. + """ + if not media: + return + + self.current_media_hovered = media + + # Update details tab (from pre-loaded data) + self.s_update_details.emit(media) + + # Cover is handled by MainWindow + self._request_track_extras_if_needed(media) + + def update_on_selection(self, media: Track | Video | Album | Mix | Playlist | Artist | None) -> None: + """Update display based on selected media item. + + This method is called when the user clicks on a track in the results list. + It persists the selection so the UI can revert to it after hover leaves. + + Args: + media (Track | Video | Album | Mix | Playlist | Artist | None): The selected media item. + """ + if not media: + return + + self.current_media_selected = media + + # Update both tabs + self.s_update_details.emit(media) + # Cover is handled by MainWindow + self._request_track_extras_if_needed(media) + + def revert_to_selection(self) -> None: + """Revert display to the currently selected media. + + Called when hover leaves the results list to prevent leaving the UI + in an inconsistent state. + """ + if self.current_media_selected: + self.update_on_selection(self.current_media_selected) + else: + self._clear_display() + + def _clear_display(self) -> None: + """Clear all displayed information and reset to default state.""" + self.current_media_hovered = None + + try: + self.lbl_title.setText("—") + self.lbl_version.setText("—") + self.lbl_artists.setText("—") + self.lbl_album.setText("—") + self.lbl_codec.setText("—") + self.lbl_bitrate.setText("—") + self.lbl_duration.setText("—") + self.lbl_release_date.setText("—") + self.lbl_popularity.setText("—") + self.lbl_bpm.setText("—") + self.lbl_isrc.setText("—") + self.lbl_track_number.setText("—") + + # Reset cover to default + path_image: str = resource_path("tidal_dl_ng/ui/default_album_image.png") + self.cover_label.setPixmap(QtGui.QPixmap(path_image)) + self.cover_url_current = "" + except RuntimeError: + pass # Widgets deleted, silently ignore + + def _on_update_details(self, media: Track | Video | Album | Mix | Playlist | Artist) -> None: + """Update details tab with media metadata. + + Args: + media (Track | Video | Album | Mix | Playlist | Artist): The media item to display. + """ + # Handle different media types + if isinstance(media, Track): + self._populate_track_details(media) + elif isinstance(media, Video): + self._populate_video_details(media) + elif isinstance(media, Album): + self._populate_album_details(media) + elif isinstance(media, Playlist | Mix): + self._populate_playlist_details(media) + elif isinstance(media, Artist): + self._populate_artist_details(media) + + def _populate_track_details(self, track: Track) -> None: + """Populate details for a Track. + + Args: + track (Track): The track object. + """ + # Title and version + title = name_builder_title(track) + self.lbl_title.setText(self._create_link("track", title)) + version = getattr(track, "version", None) + self.lbl_version.setText(safe_str(version)) + + # Artists + artist_parts = [] + if hasattr(track, "artists") and track.artists: + for artist in track.artists: + name = TrackInfoFormatter._get_artist_name(artist) + roles = TrackInfoFormatter._format_artist_roles(artist) + link = self._create_link("artist", name) + if roles and roles[0] != "main": + artist_parts.append(f"{link} ({', '.join(roles)})") + else: + artist_parts.append(link) + self.lbl_artists.setText(", ".join(artist_parts) if artist_parts else "—") + + # Album name (prefer album object's name) + album_name = None + if hasattr(track, "album") and track.album: + album_name = getattr(track.album, "name", None) or None + self.lbl_album.setText(self._create_link("album", album_name)) + + # Codec & Bitrate + codec = TrackInfoFormatter.format_codec(track) + self.lbl_codec.setText(safe_str(codec)) + bitrate = TrackInfoFormatter.format_bitrate(track) + self.lbl_bitrate.setText(safe_str(bitrate)) + + # Duration + duration = TrackInfoFormatter.format_duration(getattr(track, "duration", None)) + self.lbl_duration.setText(safe_str(duration)) + + # Release date (from album if exists) + release_date = None + if hasattr(track, "album") and track.album and hasattr(track.album, "release_date"): + release_date = TrackInfoFormatter.format_date(track.album.release_date) + self.lbl_release_date.setText(safe_str(release_date)) + + # Popularity + popularity = getattr(track, "popularity", None) + self.lbl_popularity.setText(safe_str(popularity)) + + # BPM: Show loading indicator, will be updated when extras are loaded + self.lbl_bpm.setText("⏳ Loading...") + + # ISRC + isrc = getattr(track, "isrc", None) + self.lbl_isrc.setText(safe_str(isrc)) + + # Track Number + track_number = find_attr(track, "track_number", "tracknumber", "number", "position", "track") or None + if track_number is None and hasattr(track, "album") and track.album: + track_number = find_attr(track.album, "track_number", "tracknumber", "number") or None + self.lbl_track_number.setText(safe_str(track_number)) + + def _populate_video_details(self, video: Video) -> None: + """Populate details for a Video. + + Args: + video (Video): The video object. + """ + # Title and version + title = name_builder_title(video) + self.lbl_title.setText(self._create_link("video", title)) + version = getattr(video, "version", None) + self.lbl_version.setText(safe_str(version)) + + # Artists + artists_str = extract_names_from_mixed( + getattr(video, "credits", None), match_types=("performer", "artist", "actor") + ) + if not artists_str: + artist_parts = [] + if hasattr(video, "artists") and video.artists: + for artist in video.artists: + name = TrackInfoFormatter._get_artist_name(artist) + artist_parts.append(self._create_link("artist", name)) + self.lbl_artists.setText(", ".join(artist_parts) if artist_parts else "—") + else: + self.lbl_artists.setText(self._create_link("artist", artists_str)) + + # Album name + album_name = None + if hasattr(video, "album") and video.album: + album_name = getattr(video.album, "name", None) or None + self.lbl_album.setText(self._create_link("album", album_name)) + + # Codec & Bitrate + codec = TrackInfoFormatter.format_codec(video) + self.lbl_codec.setText(safe_str(codec)) + bitrate = TrackInfoFormatter.format_bitrate(video) + self.lbl_bitrate.setText(safe_str(bitrate)) + + # Duration + duration = TrackInfoFormatter.format_duration(getattr(video, "duration", None)) + self.lbl_duration.setText(safe_str(duration)) + + # Release date + release_date = None + if hasattr(video, "album") and video.album and hasattr(video.album, "release_date"): + release_date = TrackInfoFormatter.format_date(video.album.release_date) + self.lbl_release_date.setText(safe_str(release_date)) + + # Popularity + popularity = getattr(video, "popularity", None) + self.lbl_popularity.setText(safe_str(popularity)) + + # ISRC + isrc = getattr(video, "isrc", None) + if not isrc and hasattr(video, "track"): + isrc = getattr(video.track, "isrc", None) + self.lbl_isrc.setText(safe_str(isrc)) + + def _populate_album_details(self, album: Album) -> None: + """Populate details for an Album. + + Args: + album (Album): The album object. + """ + # Title and version + title = name_builder_title(album) + self.lbl_title.setText(self._create_link("album", title)) + version = getattr(album, "version", None) + self.lbl_version.setText(safe_str(version)) + + # Artists + artist_parts = [] + if hasattr(album, "artists") and album.artists: + for artist in album.artists: + name = TrackInfoFormatter._get_artist_name(artist) + artist_parts.append(self._create_link("artist", name)) + self.lbl_artists.setText(", ".join(artist_parts) if artist_parts else "—") + + # Album name (same as title for an album) + self.lbl_album.setText(self._create_link("album", title)) + + # Codec & Bitrate + codec = TrackInfoFormatter.format_codec(album) + self.lbl_codec.setText(safe_str(codec)) + bitrate = TrackInfoFormatter.format_bitrate(album) + self.lbl_bitrate.setText(safe_str(bitrate)) + + # Duration + duration = TrackInfoFormatter.format_duration(getattr(album, "duration", None)) + self.lbl_duration.setText(safe_str(duration)) + + # Release date + release_date = TrackInfoFormatter.format_date(getattr(album, "release_date", None)) + self.lbl_release_date.setText(safe_str(release_date)) + + # Popularity + popularity = getattr(album, "popularity", None) + self.lbl_popularity.setText(safe_str(popularity)) + + self.lbl_isrc.setText("—") + + def _extract_playlist_artists(self, playlist: Playlist | Mix) -> str: + """Extract and format artists from playlist tracks.""" + if not hasattr(playlist, "tracks") or not isinstance(playlist.tracks, list | Iterable): + return "—" + + all_artists = set() + for track in playlist.tracks: + if isinstance(track, Track | Video | Album) and hasattr(track, "artists"): + for artist in track.artists: + name = TrackInfoFormatter._get_artist_name(artist) + if name and name != "Unknown Artist": + all_artists.add(name) + + if all_artists: + artist_links = [self._create_link("artist", name) for name in list(all_artists)[:5]] + return ", ".join(artist_links) + return "—" + + def _extract_playlist_release_date(self, playlist: Playlist | Mix) -> str: + """Extract release date from playlist or its first track.""" + release_date = TrackInfoFormatter.format_date(getattr(playlist, "release_date", None)) + if release_date != "N/A": + return release_date + + if hasattr(playlist, "tracks") and isinstance(playlist.tracks, list | Iterable): + for track in playlist.tracks: + if isinstance(track, Track | Video | Album) and hasattr(track, "album"): + release_date = TrackInfoFormatter.format_date(getattr(track.album, "release_date", None)) + if release_date and release_date != "N/A": + return release_date + return "N/A" + + def _populate_playlist_details(self, playlist: Playlist | Mix) -> None: + """Populate details for a Playlist or Mix. + + Args: + playlist (Playlist | Mix): The playlist or mix object. + """ + # Title and version + title = name_builder_title(playlist) + self.lbl_title.setText(self._create_link("playlist", title)) + version = getattr(playlist, "version", None) + self.lbl_version.setText(safe_str(version)) + + # Artists + artists_html = self._extract_playlist_artists(playlist) + self.lbl_artists.setText(artists_html) + + # Album name (playlist title) + album_name = getattr(playlist, "name", None) + self.lbl_album.setText(self._create_link("playlist", album_name)) + + # Codec & Bitrate + self.lbl_codec.setText("—") + self.lbl_bitrate.setText("—") + + # Duration + duration = TrackInfoFormatter.format_duration(getattr(playlist, "duration", None)) + self.lbl_duration.setText(safe_str(duration)) + + # Release date + release_date = self._extract_playlist_release_date(playlist) + self.lbl_release_date.setText(safe_str(release_date)) + + # Popularity + popularity = getattr(playlist, "popularity", None) + self.lbl_popularity.setText(safe_str(popularity)) + + self.lbl_isrc.setText("—") + + def _extract_artist_album_name(self, artist: Artist) -> str | None: + """Extract first album name from artist's albums.""" + if not hasattr(artist, "albums") or not isinstance(artist.albums, list | Iterable): + return None + + for album in artist.albums: + if isinstance(album, Album) and hasattr(album, "name"): + return getattr(album, "name", None) + return None + + def _calculate_artist_total_duration(self, artist: Artist) -> int: + """Calculate total duration of all artist's albums.""" + if not hasattr(artist, "albums") or not isinstance(artist.albums, list | Iterable): + return 0 + + total_duration = 0 + for album in artist.albums: + if isinstance(album, Album) and hasattr(album, "duration"): + album_duration = album.duration + if isinstance(album_duration, int | float): + total_duration += album_duration + return total_duration + + def _extract_artist_release_date(self, artist: Artist) -> str | None: + """Extract release date from artist's first album.""" + if not hasattr(artist, "albums") or not isinstance(artist.albums, list | Iterable): + return None + + for album in artist.albums: + if isinstance(album, Album) and hasattr(album, "release_date"): + release_date = TrackInfoFormatter.format_date(getattr(album, "release_date", None)) + if release_date and release_date != "N/A": + return release_date + return None + + def _populate_artist_details(self, artist: Artist) -> None: + """Populate details for an Artist. + + Args: + artist (Artist): The artist object. + """ + # Name + name = getattr(artist, "name", None) + self.lbl_title.setText(self._create_link("artist", name)) + + # Version + version = None + if hasattr(artist, "type"): + version = getattr(artist.type, "name", None) + elif hasattr(artist, "role"): + version = getattr(artist.role, "name", None) + self.lbl_version.setText(safe_str(version)) + + # Artists + self.lbl_artists.setText("—") + + # Album name + album_name = self._extract_artist_album_name(artist) + self.lbl_album.setText(self._create_link("album", album_name)) + + # Codec & Bitrate + self.lbl_codec.setText("—") + self.lbl_bitrate.setText("—") + + # Duration + total_duration = self._calculate_artist_total_duration(artist) + duration = TrackInfoFormatter.format_duration(total_duration) + self.lbl_duration.setText(safe_str(duration)) + + # Release date + release_date = self._extract_artist_release_date(artist) + self.lbl_release_date.setText(safe_str(release_date)) + + # Popularity + popularity = getattr(artist, "popularity", None) + self.lbl_popularity.setText(safe_str(popularity)) + + self.lbl_isrc.setText("—") diff --git a/tidal_dl_ng/ui/main.py b/tidal_dl_ng/ui/main.py index 4b60620..bc8a524 100644 --- a/tidal_dl_ng/ui/main.py +++ b/tidal_dl_ng/ui/main.py @@ -1,7 +1,7 @@ ################################################################################ ## Form generated from reading UI file 'main.ui' ## -## Created by: Qt User Interface Compiler version 6.9.0 +## Created by: Qt User Interface Compiler version 6.10.0 ## ## WARNING! All changes made in this file will be lost when recompiling UI file! ################################################################################ @@ -11,6 +11,7 @@ from PySide6.QtGui import QAction, QFont, QIcon, QPixmap from PySide6.QtWidgets import ( QAbstractItemView, QComboBox, + QFormLayout, QFrame, QHBoxLayout, QLabel, @@ -21,6 +22,7 @@ from PySide6.QtWidgets import ( QPushButton, QSizePolicy, QStatusBar, + QTabWidget, QTreeView, QTreeWidget, QTreeWidgetItem, @@ -146,9 +148,7 @@ class Ui_MainWindow: self.horizontalLayout.addLayout(self.lv_list_user) self.lv_search_result = QVBoxLayout() - # ifndef Q_OS_MAC - self.lv_search_result.setSpacing(-1) - # endif + self.lv_search_result.setSpacing(6) self.lv_search_result.setObjectName("lv_search_result") self.lh_search = QHBoxLayout() self.lh_search.setObjectName("lh_search") @@ -403,21 +403,182 @@ class Ui_MainWindow: self.lv_info_item.setObjectName("lv_info_item") self.horizontalLayout_2 = QHBoxLayout() self.horizontalLayout_2.setObjectName("horizontalLayout_2") - self.l_pm_cover = QLabel(self.w_central) - self.l_pm_cover.setObjectName("l_pm_cover") - sizePolicy3 = QSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) + self.tabWidget = QTabWidget(self.w_central) + self.tabWidget.setObjectName("tabWidget") + self.tabWidget.setEnabled(True) + sizePolicy3 = QSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Preferred) sizePolicy3.setHorizontalStretch(0) sizePolicy3.setVerticalStretch(0) - sizePolicy3.setHeightForWidth(self.l_pm_cover.sizePolicy().hasHeightForWidth()) - self.l_pm_cover.setSizePolicy(sizePolicy3) + sizePolicy3.setHeightForWidth(self.tabWidget.sizePolicy().hasHeightForWidth()) + self.tabWidget.setSizePolicy(sizePolicy3) + self.tabWidget.setMinimumSize(QSize(300, 300)) + self.tabWidget.setMaximumSize(QSize(600, 16777215)) + self.tabWidget.setUsesScrollButtons(True) + self.tabWidget.setDocumentMode(False) + self.tabWidget.setTabsClosable(False) + self.tabWidget.setMovable(True) + self.tabWidget.setTabBarAutoHide(False) + self.tab = QWidget() + self.tab.setObjectName("tab") + self.details_vlayout = QVBoxLayout(self.tab) + self.details_vlayout.setSpacing(4) + self.details_vlayout.setObjectName("details_vlayout") + self.formLayout_details = QFormLayout() + self.formLayout_details.setObjectName("formLayout_details") + self.formLayout_details.setLabelAlignment( + Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignTrailing | Qt.AlignmentFlag.AlignVCenter + ) + self.formLayout_details.setHorizontalSpacing(2) + self.formLayout_details.setVerticalSpacing(2) + self.l_label_title = QLabel(self.tab) + self.l_label_title.setObjectName("l_label_title") + + self.formLayout_details.setWidget(0, QFormLayout.ItemRole.LabelRole, self.l_label_title) + + self.lbl_title = QLabel(self.tab) + self.lbl_title.setObjectName("lbl_title") + self.lbl_title.setWordWrap(True) + + self.formLayout_details.setWidget(0, QFormLayout.ItemRole.FieldRole, self.lbl_title) + + self.l_label_version = QLabel(self.tab) + self.l_label_version.setObjectName("l_label_version") + + self.formLayout_details.setWidget(1, QFormLayout.ItemRole.LabelRole, self.l_label_version) + + self.lbl_version = QLabel(self.tab) + self.lbl_version.setObjectName("lbl_version") + + self.formLayout_details.setWidget(1, QFormLayout.ItemRole.FieldRole, self.lbl_version) + + self.l_label_artists = QLabel(self.tab) + self.l_label_artists.setObjectName("l_label_artists") + + self.formLayout_details.setWidget(2, QFormLayout.ItemRole.LabelRole, self.l_label_artists) + + self.lbl_artists = QLabel(self.tab) + self.lbl_artists.setObjectName("lbl_artists") + self.lbl_artists.setWordWrap(True) + + self.formLayout_details.setWidget(2, QFormLayout.ItemRole.FieldRole, self.lbl_artists) + + self.l_label_track_number = QLabel(self.tab) + self.l_label_track_number.setObjectName("l_label_track_number") + + self.formLayout_details.setWidget(3, QFormLayout.ItemRole.LabelRole, self.l_label_track_number) + + self.lbl_track_number = QLabel(self.tab) + self.lbl_track_number.setObjectName("lbl_track_number") + + self.formLayout_details.setWidget(3, QFormLayout.ItemRole.FieldRole, self.lbl_track_number) + + self.l_label_album = QLabel(self.tab) + self.l_label_album.setObjectName("l_label_album") + + self.formLayout_details.setWidget(4, QFormLayout.ItemRole.LabelRole, self.l_label_album) + + self.lbl_album = QLabel(self.tab) + self.lbl_album.setObjectName("lbl_album") + + self.formLayout_details.setWidget(4, QFormLayout.ItemRole.FieldRole, self.lbl_album) + + self.l_label_duration = QLabel(self.tab) + self.l_label_duration.setObjectName("l_label_duration") + + self.formLayout_details.setWidget(5, QFormLayout.ItemRole.LabelRole, self.l_label_duration) + + self.lbl_duration = QLabel(self.tab) + self.lbl_duration.setObjectName("lbl_duration") + + self.formLayout_details.setWidget(5, QFormLayout.ItemRole.FieldRole, self.lbl_duration) + + self.l_label_codec = QLabel(self.tab) + self.l_label_codec.setObjectName("l_label_codec") + + self.formLayout_details.setWidget(6, QFormLayout.ItemRole.LabelRole, self.l_label_codec) + + self.lbl_codec = QLabel(self.tab) + self.lbl_codec.setObjectName("lbl_codec") + + self.formLayout_details.setWidget(6, QFormLayout.ItemRole.FieldRole, self.lbl_codec) + + self.l_label_bitrate = QLabel(self.tab) + self.l_label_bitrate.setObjectName("l_label_bitrate") + + self.formLayout_details.setWidget(7, QFormLayout.ItemRole.LabelRole, self.l_label_bitrate) + + self.lbl_bitrate = QLabel(self.tab) + self.lbl_bitrate.setObjectName("lbl_bitrate") + + self.formLayout_details.setWidget(7, QFormLayout.ItemRole.FieldRole, self.lbl_bitrate) + + self.l_label_release_date = QLabel(self.tab) + self.l_label_release_date.setObjectName("l_label_release_date") + + self.formLayout_details.setWidget(8, QFormLayout.ItemRole.LabelRole, self.l_label_release_date) + + self.lbl_release_date = QLabel(self.tab) + self.lbl_release_date.setObjectName("lbl_release_date") + + self.formLayout_details.setWidget(8, QFormLayout.ItemRole.FieldRole, self.lbl_release_date) + + self.l_label_popularity = QLabel(self.tab) + self.l_label_popularity.setObjectName("l_label_popularity") + + self.formLayout_details.setWidget(9, QFormLayout.ItemRole.LabelRole, self.l_label_popularity) + + self.lbl_popularity = QLabel(self.tab) + self.lbl_popularity.setObjectName("lbl_popularity") + + self.formLayout_details.setWidget(9, QFormLayout.ItemRole.FieldRole, self.lbl_popularity) + + self.l_label_bpm = QLabel(self.tab) + self.l_label_bpm.setObjectName("l_label_bpm") + + self.formLayout_details.setWidget(10, QFormLayout.ItemRole.LabelRole, self.l_label_bpm) + + self.lbl_bpm = QLabel(self.tab) + self.lbl_bpm.setObjectName("lbl_bpm") + + self.formLayout_details.setWidget(10, QFormLayout.ItemRole.FieldRole, self.lbl_bpm) + + self.l_label_isrc = QLabel(self.tab) + self.l_label_isrc.setObjectName("l_label_isrc") + + self.formLayout_details.setWidget(11, QFormLayout.ItemRole.LabelRole, self.l_label_isrc) + + self.lbl_isrc = QLabel(self.tab) + self.lbl_isrc.setObjectName("lbl_isrc") + + self.formLayout_details.setWidget(11, QFormLayout.ItemRole.FieldRole, self.lbl_isrc) + + self.details_vlayout.addLayout(self.formLayout_details) + + self.tabWidget.addTab(self.tab, "") + self.tab_2 = QWidget() + self.tab_2.setObjectName("tab_2") + self.cover_vlayout = QVBoxLayout(self.tab_2) + self.cover_vlayout.setSpacing(2) + self.cover_vlayout.setObjectName("cover_vlayout") + self.l_pm_cover = QLabel(self.tab_2) + self.l_pm_cover.setObjectName("l_pm_cover") + sizePolicy4 = QSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) + sizePolicy4.setHorizontalStretch(0) + sizePolicy4.setVerticalStretch(0) + sizePolicy4.setHeightForWidth(self.l_pm_cover.sizePolicy().hasHeightForWidth()) + self.l_pm_cover.setSizePolicy(sizePolicy4) + self.l_pm_cover.setMinimumSize(QSize(280, 280)) self.l_pm_cover.setMaximumSize(QSize(280, 280)) - self.l_pm_cover.setBaseSize(QSize(0, 0)) self.l_pm_cover.setFrameShape(QFrame.Shape.NoFrame) self.l_pm_cover.setPixmap(QPixmap("default_album_image.png")) self.l_pm_cover.setScaledContents(True) self.l_pm_cover.setAlignment(Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignTop) - self.horizontalLayout_2.addWidget(self.l_pm_cover) + self.cover_vlayout.addWidget(self.l_pm_cover) + + self.tabWidget.addTab(self.tab_2, "") + + self.horizontalLayout_2.addWidget(self.tabWidget) self.lv_info_item.addLayout(self.horizontalLayout_2) @@ -497,7 +658,7 @@ class Ui_MainWindow: MainWindow.setCentralWidget(self.w_central) self.menubar = QMenuBar(MainWindow) self.menubar.setObjectName("menubar") - self.menubar.setGeometry(QRect(0, 0, 1200, 24)) + self.menubar.setGeometry(QRect(0, 0, 1200, 33)) # if QT_CONFIG(tooltip) self.menubar.setToolTip("") # endif // QT_CONFIG(tooltip) @@ -563,6 +724,8 @@ class Ui_MainWindow: self.retranslateUi(MainWindow) + self.tabWidget.setCurrentIndex(1) + QMetaObject.connectSlotsByName(MainWindow) # setupUi @@ -589,7 +752,37 @@ class Ui_MainWindow: self.pb_reload_user_lists.setText(QCoreApplication.translate("MainWindow", "Reload", None)) self.pb_download_list.setText(QCoreApplication.translate("MainWindow", "Download List", None)) self.te_debug.setPlaceholderText(QCoreApplication.translate("MainWindow", "Logs...", None)) + self.l_label_title.setText(QCoreApplication.translate("MainWindow", "Title", None)) + self.lbl_title.setText("") + self.l_label_version.setText(QCoreApplication.translate("MainWindow", "Version", None)) + self.lbl_version.setText("") + self.l_label_artists.setText(QCoreApplication.translate("MainWindow", "Artists", None)) + self.lbl_artists.setText("") + self.l_label_track_number.setText(QCoreApplication.translate("MainWindow", "Track #", None)) + self.lbl_track_number.setText("") + self.l_label_album.setText(QCoreApplication.translate("MainWindow", "Album", None)) + self.lbl_album.setText("") + self.l_label_duration.setText(QCoreApplication.translate("MainWindow", "Duration", None)) + self.lbl_duration.setText("") + self.l_label_codec.setText(QCoreApplication.translate("MainWindow", "Codec", None)) + self.lbl_codec.setText("") + self.l_label_bitrate.setText(QCoreApplication.translate("MainWindow", "Bitrate", None)) + self.lbl_bitrate.setText("") + self.l_label_release_date.setText(QCoreApplication.translate("MainWindow", "Release Date", None)) + self.lbl_release_date.setText("") + self.l_label_popularity.setText(QCoreApplication.translate("MainWindow", "Popularity", None)) + self.lbl_popularity.setText("") + self.l_label_bpm.setText(QCoreApplication.translate("MainWindow", "BPM", None)) + self.lbl_bpm.setText("") + self.l_label_isrc.setText(QCoreApplication.translate("MainWindow", "ISRC", None)) + self.lbl_isrc.setText("") + self.tabWidget.setTabText( + self.tabWidget.indexOf(self.tab), QCoreApplication.translate("MainWindow", "Details", None) + ) self.l_pm_cover.setText("") + self.tabWidget.setTabText( + self.tabWidget.indexOf(self.tab_2), QCoreApplication.translate("MainWindow", "Cover Art", None) + ) self.l_h_queue_download.setText(QCoreApplication.translate("MainWindow", "Download Queue", None)) ___qtreewidgetitem4 = self.tr_queue_download.headerItem() ___qtreewidgetitem4.setText(5, QCoreApplication.translate("MainWindow", "Quality Video", None)) diff --git a/tidal_dl_ng/ui/main.ui b/tidal_dl_ng/ui/main.ui index 23d98e6..668b647 100644 --- a/tidal_dl_ng/ui/main.ui +++ b/tidal_dl_ng/ui/main.ui @@ -197,7 +197,7 @@ <item> <layout class="QVBoxLayout" name="lv_search_result" stretch="0,0,0,0"> <property name="spacing"> - <number>-1</number> + <number>6</number> </property> <item> <layout class="QHBoxLayout" name="lh_search"> @@ -523,40 +523,290 @@ <item> <layout class="QHBoxLayout" name="horizontalLayout_2"> <item> - <widget class="QLabel" name="l_pm_cover"> + <widget class="QTabWidget" name="tabWidget"> + <property name="enabled"> + <bool>true</bool> + </property> <property name="sizePolicy"> - <sizepolicy hsizetype="Fixed" vsizetype="Fixed"> + <sizepolicy hsizetype="Fixed" vsizetype="Preferred"> <horstretch>0</horstretch> <verstretch>0</verstretch> </sizepolicy> </property> - <property name="maximumSize"> + <property name="minimumSize"> <size> - <width>280</width> - <height>280</height> + <width>300</width> + <height>300</height> </size> </property> - <property name="baseSize"> + <property name="maximumSize"> <size> - <width>0</width> - <height>0</height> + <width>600</width> + <height>16777215</height> </size> </property> - <property name="frameShape"> - <enum>QFrame::Shape::NoFrame</enum> + <property name="currentIndex"> + <number>1</number> </property> - <property name="text"> - <string/> + <property name="usesScrollButtons"> + <bool>true</bool> + </property> + <property name="documentMode"> + <bool>false</bool> </property> - <property name="pixmap"> - <pixmap>default_album_image.png</pixmap> + <property name="tabsClosable"> + <bool>false</bool> </property> - <property name="scaledContents"> + <property name="movable"> <bool>true</bool> </property> - <property name="alignment"> - <set>Qt::AlignmentFlag::AlignHCenter|Qt::AlignmentFlag::AlignTop</set> + <property name="tabBarAutoHide"> + <bool>false</bool> </property> + <widget class="QWidget" name="tab"> + <attribute name="title"> + <string>Details</string> + </attribute> + <layout class="QVBoxLayout" name="details_vlayout"> + <property name="spacing"> + <number>4</number> + </property> + <item> + <layout class="QFormLayout" name="formLayout_details"> + <property name="labelAlignment"> + <set>Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter</set> + </property> + <property name="horizontalSpacing"> + <number>2</number> + </property> + <property name="verticalSpacing"> + <number>2</number> + </property> + <item row="0" column="0"> + <widget class="QLabel" name="l_label_title"> + <property name="text"> + <string>Title</string> + </property> + </widget> + </item> + <item row="0" column="1"> + <widget class="QLabel" name="lbl_title"> + <property name="text"> + <string/> + </property> + <property name="wordWrap"> + <bool>true</bool> + </property> + </widget> + </item> + <item row="1" column="0"> + <widget class="QLabel" name="l_label_version"> + <property name="text"> + <string>Version</string> + </property> + </widget> + </item> + <item row="1" column="1"> + <widget class="QLabel" name="lbl_version"> + <property name="text"> + <string/> + </property> + </widget> + </item> + <item row="2" column="0"> + <widget class="QLabel" name="l_label_artists"> + <property name="text"> + <string>Artists</string> + </property> + </widget> + </item> + <item row="2" column="1"> + <widget class="QLabel" name="lbl_artists"> + <property name="text"> + <string/> + </property> + <property name="wordWrap"> + <bool>true</bool> + </property> + </widget> + </item> + <item row="3" column="0"> + <widget class="QLabel" name="l_label_track_number"> + <property name="text"> + <string>Track #</string> + </property> + </widget> + </item> + <item row="3" column="1"> + <widget class="QLabel" name="lbl_track_number"> + <property name="text"> + <string/> + </property> + </widget> + </item> + <item row="4" column="0"> + <widget class="QLabel" name="l_label_album"> + <property name="text"> + <string>Album</string> + </property> + </widget> + </item> + <item row="4" column="1"> + <widget class="QLabel" name="lbl_album"> + <property name="text"> + <string/> + </property> + </widget> + </item> + <item row="5" column="0"> + <widget class="QLabel" name="l_label_duration"> + <property name="text"> + <string>Duration</string> + </property> + </widget> + </item> + <item row="5" column="1"> + <widget class="QLabel" name="lbl_duration"> + <property name="text"> + <string/> + </property> + </widget> + </item> + <item row="6" column="0"> + <widget class="QLabel" name="l_label_codec"> + <property name="text"> + <string>Codec</string> + </property> + </widget> + </item> + <item row="6" column="1"> + <widget class="QLabel" name="lbl_codec"> + <property name="text"> + <string/> + </property> + </widget> + </item> + <item row="7" column="0"> + <widget class="QLabel" name="l_label_bitrate"> + <property name="text"> + <string>Bitrate</string> + </property> + </widget> + </item> + <item row="7" column="1"> + <widget class="QLabel" name="lbl_bitrate"> + <property name="text"> + <string/> + </property> + </widget> + </item> + <item row="8" column="0"> + <widget class="QLabel" name="l_label_release_date"> + <property name="text"> + <string>Release Date</string> + </property> + </widget> + </item> + <item row="8" column="1"> + <widget class="QLabel" name="lbl_release_date"> + <property name="text"> + <string/> + </property> + </widget> + </item> + <item row="9" column="0"> + <widget class="QLabel" name="l_label_popularity"> + <property name="text"> + <string>Popularity</string> + </property> + </widget> + </item> + <item row="9" column="1"> + <widget class="QLabel" name="lbl_popularity"> + <property name="text"> + <string/> + </property> + </widget> + </item> + <item row="10" column="0"> + <widget class="QLabel" name="l_label_bpm"> + <property name="text"> + <string>BPM</string> + </property> + </widget> + </item> + <item row="10" column="1"> + <widget class="QLabel" name="lbl_bpm"> + <property name="text"> + <string/> + </property> + </widget> + </item> + <item row="11" column="0"> + <widget class="QLabel" name="l_label_isrc"> + <property name="text"> + <string>ISRC</string> + </property> + </widget> + </item> + <item row="11" column="1"> + <widget class="QLabel" name="lbl_isrc"> + <property name="text"> + <string/> + </property> + </widget> + </item> + </layout> + </item> + </layout> + </widget> + <widget class="QWidget" name="tab_2"> + <attribute name="title"> + <string>Cover Art</string> + </attribute> + <layout class="QVBoxLayout" name="cover_vlayout"> + <property name="spacing"> + <number>2</number> + </property> + <item> + <widget class="QLabel" name="l_pm_cover"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Fixed" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="minimumSize"> + <size> + <width>280</width> + <height>280</height> + </size> + </property> + <property name="maximumSize"> + <size> + <width>280</width> + <height>280</height> + </size> + </property> + <property name="frameShape"> + <enum>QFrame::Shape::NoFrame</enum> + </property> + <property name="text"> + <string/> + </property> + <property name="pixmap"> + <pixmap>default_album_image.png</pixmap> + </property> + <property name="scaledContents"> + <bool>true</bool> + </property> + <property name="alignment"> + <set>Qt::AlignmentFlag::AlignHCenter|Qt::AlignmentFlag::AlignTop</set> + </property> + </widget> + </item> + </layout> + </widget> </widget> </item> </layout> @@ -706,7 +956,7 @@ <x>0</x> <y>0</y> <width>1200</width> - <height>24</height> + <height>33</height> </rect> </property> <property name="toolTip"> diff --git a/tidal_dl_ng/ui/media_details_helper.py b/tidal_dl_ng/ui/media_details_helper.py new file mode 100644 index 0000000..bb56ab1 --- /dev/null +++ b/tidal_dl_ng/ui/media_details_helper.py @@ -0,0 +1,115 @@ +"""Helpers for populating media details in InfoTabWidget - Reduces code duplication.""" + +from tidal_dl_ng.helper.metadata_utils import find_attr, safe_str +from tidal_dl_ng.helper.tidal import name_builder_title +from tidal_dl_ng.ui.info_tab_widget import TrackInfoFormatter + + +class MediaDetailsHelper: + """Helper class to populate media details, reducing duplication in InfoTabWidget.""" + + @staticmethod + def populate_basic_fields(labels: dict, media, media_type: str = "track") -> None: + """Populate fields common to all media types. + + Args: + labels: Dictionary mapping field names to QLabel widgets + media: The media object (Track, Video, Album, etc.) + media_type: Type of media ("track", "video", "album") + """ + # Title and version + title = name_builder_title(media) if media_type != "album" else getattr(media, "name", None) + version = getattr(media, "version", None) + labels["title"].setText(safe_str(title)) + labels["version"].setText(safe_str(version)) + + # Artists + artists = TrackInfoFormatter.format_artists(media) + labels["artists"].setText(safe_str(artists)) + + # Duration + duration = TrackInfoFormatter.format_duration(getattr(media, "duration", None)) + labels["duration"].setText(safe_str(duration)) + + # Popularity + popularity = getattr(media, "popularity", None) + labels["popularity"].setText(safe_str(popularity)) + + @staticmethod + def populate_album_fields(labels: dict, media) -> None: + """Populate album-related fields.""" + album_name = None + release_date = None + + if hasattr(media, "album") and media.album: + album_name = getattr(media.album, "name", None) + if hasattr(media.album, "release_date"): + release_date = TrackInfoFormatter.format_date(media.album.release_date) + elif hasattr(media, "release_date"): + release_date = TrackInfoFormatter.format_date(media.release_date) + + labels["album"].setText(safe_str(album_name)) + labels["release_date"].setText(safe_str(release_date)) + + @staticmethod + def populate_technical_fields(labels: dict, media) -> None: + """Populate technical fields (codec, bitrate, ISRC, track number).""" + # Codec & Bitrate + codec = TrackInfoFormatter.format_codec(media) + labels["codec"].setText(safe_str(codec)) + bitrate = TrackInfoFormatter.format_bitrate(media) + labels["bitrate"].setText(safe_str(bitrate)) + + # ISRC + isrc = getattr(media, "isrc", None) + labels["isrc"].setText(safe_str(isrc)) + + # Track Number + track_number = find_attr(media, "track_number", "tracknumber", "number", "position") + if not track_number and hasattr(media, "album") and media.album: + track_number = find_attr(media.album, "track_number", "tracknumber") + labels["track_number"].setText(safe_str(track_number)) + + @staticmethod + def populate_metadata_fields(labels: dict, media) -> None: + """Populate metadata fields (BPM, Label, Producers, Composers).""" + # BPM + bpm = find_attr(media, "bpm", "tempo") + if not bpm and hasattr(media, "album") and media.album: + bpm = find_attr(media.album, "bpm", "tempo") + labels["bpm"].setText(safe_str(bpm)) + + # Label + label = None + if hasattr(media, "album") and media.album: + label = find_attr(media.album, "label", "label_name", "recordLabel") + if not label: + label = find_attr(media, "label", "label_name", "recordLabel") + labels["label"].setText(safe_str(label)) + + # Producers/Composers + producers = find_attr(media, "producers", "producer") + labels["producers"].setText(safe_str(producers) if producers else "—") + + composers = find_attr(media, "composers", "composer") + labels["composers"].setText(safe_str(composers) if composers else "—") + + @staticmethod + def populate_genres(labels: dict, media) -> None: + """Extract and populate genre information with filtering.""" + genres = find_attr(media, "genres", "genre") + if not genres and hasattr(media, "album") and media.album: + genres = find_attr(media.album, "genres", "genre") + + if not genres: + labels["genres"].setText("—") + return + + # Normalize to list and filter technical tags + genre_list = genres if isinstance(genres, list | tuple) else [genres] + excluded = ("atmos", "dolby", "hq", "lossless", "mqa", "flac", "wav", "pcm", "audio", "bit", "track") + filtered = [ + str(g) for g in genre_list if g and isinstance(g, str) and not any(kw in g.lower() for kw in excluded) + ] + + labels["genres"].setText(safe_str(", ".join(filtered)) if filtered else "—") |
