- Introduced clickable metadata links (tracks, albums, artists, playlists, videos). - Left-click triggers in-app search; Ctrl+click or right-click opens browser search. - Enhanced label interactions and added tooltips for actionable fields. - Simplified artist, album, and playlist rendering with rich links. - Extended search functionality with URL-safe encoding and type mapping.
884 lines
34 KiB
Python
884 lines
34 KiB
Python
"""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("—")
|