diff options
Diffstat (limited to 'tidal_dl_ng/ui')
| -rw-r--r-- | tidal_dl_ng/ui/info_tab_widget.py | 833 | ||||
| -rw-r--r-- | tidal_dl_ng/ui/main.py | 217 | ||||
| -rw-r--r-- | tidal_dl_ng/ui/main.ui | 288 | ||||
| -rw-r--r-- | tidal_dl_ng/ui/media_details_helper.py | 115 |
4 files changed, 1422 insertions, 31 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 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..191bc38 --- /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 "—") |
