summaryrefslogtreecommitdiff
path: root/tidal_dl_ng
diff options
context:
space:
mode:
Diffstat (limited to 'tidal_dl_ng')
-rw-r--r--tidal_dl_ng/cache.py55
-rw-r--r--tidal_dl_ng/download.py169
-rw-r--r--tidal_dl_ng/gui.py1728
-rw-r--r--tidal_dl_ng/gui_covers.py161
-rw-r--r--tidal_dl_ng/gui_playlist.py384
-rw-r--r--tidal_dl_ng/gui_queue.py296
-rw-r--r--tidal_dl_ng/gui_search.py220
-rw-r--r--tidal_dl_ng/helper/hover_manager.py187
-rw-r--r--tidal_dl_ng/helper/metadata_utils.py227
-rw-r--r--tidal_dl_ng/helper/tidal.py340
-rw-r--r--tidal_dl_ng/metadata.py69
-rw-r--r--tidal_dl_ng/ui/info_tab_widget.py883
-rw-r--r--tidal_dl_ng/ui/main.py217
-rw-r--r--tidal_dl_ng/ui/main.ui288
-rw-r--r--tidal_dl_ng/ui/media_details_helper.py115
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 "—")