summaryrefslogtreecommitdiff
path: root/tidal_dl_ng/ui/info_tab_widget.py
diff options
context:
space:
mode:
Diffstat (limited to 'tidal_dl_ng/ui/info_tab_widget.py')
-rw-r--r--tidal_dl_ng/ui/info_tab_widget.py833
1 files changed, 833 insertions, 0 deletions
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..6979833
--- /dev/null
+++ b/tidal_dl_ng/ui/info_tab_widget.py
@@ -0,0 +1,833 @@
+"""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 = InfoTabWidget._get_artist_name(artist)
+ roles = InfoTabWidget._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()
+
+ 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()
+
+ 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)
+ version = getattr(track, "version", None)
+
+ # Use _safe_str for all displayed values to avoid passing non-str types to QLabel.setText
+ self.lbl_title.setText(safe_str(title))
+ self.lbl_version.setText(safe_str(version))
+
+ # Artists
+ artists = TrackInfoFormatter.format_artists(track)
+ self.lbl_artists.setText(safe_str(artists))
+
+ # 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(safe_str(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
+ # The BPM comes from async extras, not from the track object itself
+ self.lbl_bpm.setText("⏳ Loading...")
+
+ # ISRC
+ isrc = getattr(track, "isrc", None)
+ self.lbl_isrc.setText(safe_str(isrc))
+
+ # Track Number: try multiple possible attribute names and album track info
+ 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:
+ # Some album objects contain track indexing information
+ track_number = find_attr(track.album, ["track_number", "tracknumber", "number"]) or None
+ self.lbl_track_number.setText(safe_str(track_number))
+
+ # Note: Label, Genres, Producers and Composers fields are not available in TIDAL API - removed from display
+
+ 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)
+ version = getattr(video, "version", None)
+
+ self.lbl_title.setText(safe_str(title))
+ self.lbl_version.setText(safe_str(version))
+
+ # Artists (from credits if available)
+ artists = extract_names_from_mixed(
+ getattr(video, "credits", None), match_types=("performer", "artist", "actor")
+ )
+ if not artists:
+ # Fallback to regular artist field
+ artists = TrackInfoFormatter.format_artists(video)
+ self.lbl_artists.setText(safe_str(artists))
+
+ # Album name (from album object if available)
+ album_name = None
+ if hasattr(video, "album") and video.album:
+ album_name = getattr(video.album, "name", None) or None
+ self.lbl_album.setText(safe_str(album_name))
+
+ # Codec & Bitrate (inherited from Track)
+ 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 (from album if exists)
+ 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 (from audio track if available)
+ 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))
+
+ # Note: Video-specific fields (resolution, frame rate) and genres are not displayed
+ # as the required labels are not available in the UI
+
+ 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)
+ version = getattr(album, "version", None)
+
+ self.lbl_title.setText(safe_str(title))
+ self.lbl_version.setText(safe_str(version))
+
+ # Artists (from album object)
+ artists = TrackInfoFormatter.format_artists(album)
+ self.lbl_artists.setText(safe_str(artists))
+
+ # Album name
+ album_name = getattr(album, "name", None)
+ self.lbl_album.setText(safe_str(album_name))
+
+ # Codec & Bitrate: not typically relevant for albums, but can inherit from first track
+ 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: total duration of the album
+ 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: album popularity if available
+ popularity = getattr(album, "popularity", None)
+ self.lbl_popularity.setText(safe_str(popularity))
+
+ # ISRC: not typically available at album level, clear field
+ self.lbl_isrc.setText("—")
+
+ # Note: Genres, Label, Producers and Composers fields are not available in TIDAL API - removed from display
+
+ 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):
+ artists = TrackInfoFormatter.format_artists(track)
+ if artists and artists != "Unknown Artist":
+ all_artists.update(artists.split(", "))
+
+ if all_artists:
+ return ", ".join(list(all_artists)[:3])
+ 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)
+ version = getattr(playlist, "version", None)
+
+ self.lbl_title.setText(safe_str(title))
+ self.lbl_version.setText(safe_str(version))
+
+ # Artists: try to combine artists from all tracks
+ artists = self._extract_playlist_artists(playlist)
+ self.lbl_artists.setText(safe_str(artists))
+
+ # Album name: prefer playlist name, then mix/album name
+ album_name = getattr(playlist, "name", None)
+ if not album_name and hasattr(playlist, "album"):
+ album_name = getattr(playlist.album, "name", None)
+ self.lbl_album.setText(safe_str(album_name))
+
+ # Codec & Bitrate: not typically relevant for playlists, clear fields
+ self.lbl_codec.setText("—")
+ self.lbl_bitrate.setText("—")
+
+ # Duration: total duration of the playlist/mix
+ 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: playlist popularity if available
+ popularity = getattr(playlist, "popularity", None)
+ self.lbl_popularity.setText(safe_str(popularity))
+
+ # ISRC: not typically available at playlist level, clear field
+ self.lbl_isrc.setText("—")
+
+ # Note: Genres, Label, Producers and Composers fields are not available in TIDAL API - removed from display
+
+ 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: prefer artist's own name, fallback to common name
+ name = getattr(artist, "name", None)
+ if not name and hasattr(artist, "common_name"):
+ name = getattr(artist.common_name, "name", None)
+ self.lbl_title.setText(safe_str(name))
+
+ # Version: use artist type or role if available
+ 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: typically not relevant for single artist, clear field
+ self.lbl_artists.setText("—")
+
+ # Album name: try to use first album name if available
+ album_name = self._extract_artist_album_name(artist)
+ self.lbl_album.setText(safe_str(album_name))
+
+ # Codec & Bitrate: not relevant for artists, clear fields
+ self.lbl_codec.setText("—")
+ self.lbl_bitrate.setText("—")
+
+ # Duration: total duration of all albums/tracks by the artist
+ total_duration = self._calculate_artist_total_duration(artist)
+ duration = TrackInfoFormatter.format_duration(total_duration)
+ self.lbl_duration.setText(safe_str(duration))
+
+ # Release date: try to use release date of the first album
+ release_date = self._extract_artist_release_date(artist)
+ self.lbl_release_date.setText(safe_str(release_date))
+
+ # Popularity: artist popularity if available
+ popularity = getattr(artist, "popularity", None)
+ self.lbl_popularity.setText(safe_str(popularity))
+
+ # ISRC: not typically available at artist level, clear field
+ self.lbl_isrc.setText("—")
+
+ # Note: Genres, Label, Producers and Composers fields are not available in TIDAL API - removed from display