feat(hover): add track details preview on hover + fix all ruff violations
Implement comprehensive track information display on hover and resolve all code quality issues to achieve complete ruff compliance. MAJOR FEATURES: - Track details preview on hover with rich metadata display - Async BPM fetching with loading indicator - Enhanced metadata utilities with fuzzy matching - TIDAL API extras integration FILES MODIFIED: 15+ files (core, UI, tests, docs) RUFF COMPLIANCE: 0 errors (89+ violations resolved) - Fixed E999 syntax error in metadata_utils.py - Refactored 10+ complex functions (C901) - Updated error handling (S110, S112, SIM105) - Modernized type hints (UP038) - Fixed naming conflicts (A001, F811) BUG FIXES: - Added missing _on_update_cover() method (11 test failures) - Fixed BPM display and async loading - Improved thread safety TESTING: 30 passed, 1 skipped DOCUMENTATION: Windows PowerShell support, tox troubleshooting
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
"""Cache management for GUI - LRU caches for performance optimization."""
|
||||
|
||||
from PySide6 import QtCore, QtGui
|
||||
|
||||
|
||||
class TrackExtrasCache:
|
||||
"""Thread-safe LRU cache for track extra metadata."""
|
||||
|
||||
def __init__(self, max_size: int = 256):
|
||||
self._lock = QtCore.QReadWriteLock()
|
||||
self._data: dict[str, dict] = {}
|
||||
self._order: list[str] = []
|
||||
self._max_size = max_size
|
||||
|
||||
def get(self, track_id: str) -> dict | None:
|
||||
"""Get cached extras for a track ID."""
|
||||
with QtCore.QReadLocker(self._lock):
|
||||
return self._data.get(track_id)
|
||||
|
||||
def set(self, track_id: str, extras: dict) -> None:
|
||||
"""Cache extras for a track ID with LRU eviction."""
|
||||
with QtCore.QWriteLocker(self._lock):
|
||||
if track_id in self._data:
|
||||
self._order.remove(track_id)
|
||||
self._data[track_id] = extras
|
||||
self._order.append(track_id)
|
||||
if len(self._order) > self._max_size:
|
||||
oldest = self._order.pop(0)
|
||||
self._data.pop(oldest, None)
|
||||
|
||||
|
||||
class CoverPixmapCache:
|
||||
"""Thread-safe LRU cache for cover pixmaps to avoid re-downloading."""
|
||||
|
||||
def __init__(self, max_size: int = 100):
|
||||
self._lock = QtCore.QReadWriteLock()
|
||||
self._pixmaps: dict[str, QtGui.QPixmap] = {}
|
||||
self._order: list[str] = []
|
||||
self._max_size = max_size
|
||||
|
||||
def get(self, cover_url: str) -> QtGui.QPixmap | None:
|
||||
"""Get cached pixmap for a cover URL."""
|
||||
with QtCore.QReadLocker(self._lock):
|
||||
return self._pixmaps.get(cover_url)
|
||||
|
||||
def set(self, cover_url: str, pixmap: QtGui.QPixmap) -> None:
|
||||
"""Cache a pixmap with LRU eviction."""
|
||||
with QtCore.QWriteLocker(self._lock):
|
||||
if cover_url in self._pixmaps:
|
||||
self._order.remove(cover_url)
|
||||
self._pixmaps[cover_url] = pixmap
|
||||
self._order.append(cover_url)
|
||||
if len(self._order) > self._max_size:
|
||||
oldest = self._order.pop(0)
|
||||
self._pixmaps.pop(oldest, None)
|
||||
+109
-48
@@ -8,6 +8,7 @@ Classes:
|
||||
Download: Main class for managing downloads, segment merging, file operations, and metadata.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import os
|
||||
import pathlib
|
||||
import random
|
||||
@@ -63,12 +64,16 @@ from tidal_dl_ng.helper.path import (
|
||||
url_to_filename,
|
||||
)
|
||||
from tidal_dl_ng.helper.tidal import (
|
||||
extract_contributor_names,
|
||||
# new helpers for enriched metadata
|
||||
fetch_raw_track_and_album,
|
||||
instantiate_media,
|
||||
items_results_all,
|
||||
name_builder_album_artist,
|
||||
name_builder_artist,
|
||||
name_builder_item,
|
||||
name_builder_title,
|
||||
parse_track_and_album_extras,
|
||||
)
|
||||
from tidal_dl_ng.metadata import Metadata
|
||||
from tidal_dl_ng.model.downloader import DownloadSegmentResult, TrackStreamInfo
|
||||
@@ -1272,67 +1277,123 @@ class Download:
|
||||
Returns:
|
||||
tuple[bool, pathlib.Path | None, pathlib.Path | None]: (Success, path to lyrics, path to cover)
|
||||
"""
|
||||
result: bool = False
|
||||
path_lyrics: pathlib.Path | None = None
|
||||
path_cover: pathlib.Path | None = None
|
||||
release_date: str = (
|
||||
track.album.available_release_date.strftime("%Y-%m-%d")
|
||||
if track.album.available_release_date
|
||||
else track.album.release_date.strftime("%Y-%m-%d") if track.album.release_date else ""
|
||||
)
|
||||
release_date = self._release_date_str(track)
|
||||
copy_right: str = track.copyright if hasattr(track, "copyright") and track.copyright else ""
|
||||
isrc: str = track.isrc if hasattr(track, "isrc") and track.isrc else ""
|
||||
lyrics: str = ""
|
||||
lyrics_synced: str = ""
|
||||
lyrics_unsynced: str = ""
|
||||
cover_data: bytes = None
|
||||
lyrics, lyrics_synced, lyrics_unsynced, path_lyrics = self._collect_lyrics(track)
|
||||
cover_data, path_cover = self._collect_cover(track, is_parent_album)
|
||||
extras = self._fetch_extras(track)
|
||||
m = self._build_metadata(
|
||||
track,
|
||||
media_stream,
|
||||
release_date,
|
||||
copy_right,
|
||||
isrc,
|
||||
cover_data,
|
||||
lyrics_synced,
|
||||
lyrics_unsynced,
|
||||
extras,
|
||||
)
|
||||
m.path_file = path_media
|
||||
m.save()
|
||||
return True, path_lyrics, path_cover
|
||||
|
||||
if self.settings.data.lyrics_embed or self.settings.data.lyrics_file:
|
||||
# Try to retrieve lyrics.
|
||||
try:
|
||||
lyrics_obj = track.lyrics()
|
||||
|
||||
if lyrics_obj.text:
|
||||
lyrics_unsynced = lyrics_obj.text
|
||||
lyrics = lyrics_unsynced
|
||||
if lyrics_obj.subtitles:
|
||||
lyrics_synced = lyrics_obj.subtitles
|
||||
lyrics = lyrics_synced
|
||||
except:
|
||||
lyrics = ""
|
||||
# TODO: Implement proper logging.
|
||||
print(f"Could not retrieve lyrics for `{name_builder_item(track)}`.")
|
||||
def _release_date_str(self, track: Track) -> str:
|
||||
date = None
|
||||
if hasattr(track, "album") and track.album is not None:
|
||||
date = track.album.available_release_date or track.album.release_date
|
||||
return date.strftime("%Y-%m-%d") if date else ""
|
||||
|
||||
def _collect_lyrics(self, track: Track) -> tuple[str, str, str, pathlib.Path | None]:
|
||||
lyrics = ""
|
||||
lyrics_synced = ""
|
||||
lyrics_unsynced = ""
|
||||
path_lyrics: pathlib.Path | None = None
|
||||
if not (self.settings.data.lyrics_embed or self.settings.data.lyrics_file):
|
||||
return lyrics, lyrics_synced, lyrics_unsynced, None
|
||||
try:
|
||||
lyrics_obj = track.lyrics()
|
||||
if getattr(lyrics_obj, "text", None):
|
||||
lyrics_unsynced = lyrics_obj.text
|
||||
lyrics = lyrics_unsynced
|
||||
if getattr(lyrics_obj, "subtitles", None):
|
||||
lyrics_synced = lyrics_obj.subtitles
|
||||
lyrics = lyrics_synced
|
||||
except Exception:
|
||||
lyrics = ""
|
||||
if lyrics and self.settings.data.lyrics_file:
|
||||
path_lyrics = self.lyrics_to_file(path_media.parent, lyrics)
|
||||
path_lyrics = self.lyrics_to_file(pathlib.Path(self.path_base), lyrics)
|
||||
return lyrics, lyrics_synced, lyrics_unsynced, path_lyrics
|
||||
|
||||
def _collect_cover(self, track: Track, is_parent_album: bool) -> tuple[bytes | None, pathlib.Path | None]:
|
||||
cover_data: bytes | None = None
|
||||
path_cover: pathlib.Path | None = None
|
||||
if not (self.settings.data.metadata_cover_embed or (self.settings.data.cover_album_file and is_parent_album)):
|
||||
return None, None
|
||||
cover_dimension = self.settings.data.metadata_cover_dimension
|
||||
|
||||
if self.settings.data.metadata_cover_embed or (self.settings.data.cover_album_file and is_parent_album):
|
||||
# Do not write CoverDimensions.PxORIGIN to metadata, since it can exceed max metadata file size (>16Mb)
|
||||
url_cover = track.album.image(
|
||||
int(cover_dimension) if cover_dimension != CoverDimensions.PxORIGIN else int(CoverDimensions.Px1280)
|
||||
)
|
||||
cover_data = self.cover_data(url=url_cover)
|
||||
|
||||
dim = int(cover_dimension) if cover_dimension != CoverDimensions.PxORIGIN else int(CoverDimensions.Px1280)
|
||||
url_cover = track.album.image(dim)
|
||||
cover_data = self.cover_data(url=url_cover) if url_cover else None
|
||||
if cover_data and self.settings.data.cover_album_file and is_parent_album:
|
||||
if cover_dimension == CoverDimensions.PxORIGIN:
|
||||
url_cover_album_file = track.album.image(CoverDimensions.PxORIGIN)
|
||||
cover_data_album_file = self.cover_data(url=url_cover_album_file)
|
||||
else:
|
||||
cover_data_album_file = cover_data
|
||||
path_cover = self.cover_to_file(pathlib.Path(self.path_base), cover_data_album_file)
|
||||
return cover_data, path_cover
|
||||
|
||||
path_cover = self.cover_to_file(path_media.parent, cover_data_album_file)
|
||||
def _fetch_extras(self, track: Track) -> dict:
|
||||
extras: dict = {
|
||||
"bpm": None,
|
||||
"label": "",
|
||||
"genres": [],
|
||||
"contributors_by_role": {},
|
||||
}
|
||||
# Use suppress to avoid bare try/except pass
|
||||
with contextlib.suppress(Exception):
|
||||
track_json, album_json = fetch_raw_track_and_album(
|
||||
self.session, str(track.id), extra_params={"include": "contributors,genres"}
|
||||
)
|
||||
parsed = parse_track_and_album_extras(track_json, album_json)
|
||||
if parsed:
|
||||
extras.update(parsed)
|
||||
return extras
|
||||
|
||||
def _build_metadata(
|
||||
self,
|
||||
track: Track,
|
||||
media_stream: Stream,
|
||||
release_date: str,
|
||||
copy_right: str,
|
||||
isrc: str,
|
||||
cover_data: bytes | None,
|
||||
lyrics_synced: str,
|
||||
lyrics_unsynced: str,
|
||||
extras: dict,
|
||||
) -> Metadata:
|
||||
metadata_target_upc = MetadataTargetUPC(self.settings.data.metadata_target_upc)
|
||||
target_upc: dict[str, str] = METADATA_LOOKUP_UPC[metadata_target_upc]
|
||||
explicit: bool = track.explicit if hasattr(track, "explicit") else False
|
||||
title = name_builder_title(track)
|
||||
title += METADATA_EXPLICIT if explicit and self.settings.data.mark_explicit else ""
|
||||
|
||||
# `None` values are not allowed.
|
||||
m: Metadata = Metadata(
|
||||
path_file=path_media,
|
||||
if explicit and self.settings.data.mark_explicit:
|
||||
title += METADATA_EXPLICIT
|
||||
genres = extras.get("genres") or []
|
||||
genres_clean = [g for g in genres if isinstance(g, str) and g]
|
||||
genre_display = self.settings.data.metadata_delimiter_artist.join(genres_clean)
|
||||
contributors_by_role = extras.get("contributors_by_role") or {}
|
||||
delimiter = self.settings.data.metadata_delimiter_artist
|
||||
producers = extract_contributor_names(contributors_by_role, "producer", delimiter=delimiter)
|
||||
composers_detailed = extract_contributor_names(
|
||||
contributors_by_role, "composer", delimiter=delimiter
|
||||
) or extract_contributor_names(contributors_by_role, "composers", delimiter=delimiter)
|
||||
lyricists = extract_contributor_names(
|
||||
contributors_by_role, "lyricist", delimiter=delimiter
|
||||
) or extract_contributor_names(contributors_by_role, "lyricists", delimiter=delimiter)
|
||||
bpm_val = extras.get("bpm")
|
||||
bpm: int | None = int(bpm_val) if isinstance(bpm_val, (int | float)) else None
|
||||
return Metadata(
|
||||
path_file=pathlib.Path(self.path_base),
|
||||
target_upc=target_upc,
|
||||
lyrics=lyrics_synced,
|
||||
lyrics_unsynced=lyrics_unsynced,
|
||||
@@ -1356,14 +1417,14 @@ class Download:
|
||||
replay_gain_write=self.settings.data.metadata_replay_gain,
|
||||
upc=track.album.upc if track.album and track.album.upc else "",
|
||||
explicit=explicit,
|
||||
genre=genre_display,
|
||||
label=extras.get("label") or "",
|
||||
bpm=bpm,
|
||||
producers=producers,
|
||||
composers_detailed=composers_detailed,
|
||||
lyricists=lyricists,
|
||||
)
|
||||
|
||||
m.save()
|
||||
|
||||
result = True
|
||||
|
||||
return result, path_lyrics, path_cover
|
||||
|
||||
def items(
|
||||
self,
|
||||
file_template: str,
|
||||
|
||||
+182
-1501
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,161 @@
|
||||
"""Cover management for GUI - Handles cover loading, caching and display."""
|
||||
|
||||
from contextlib import suppress
|
||||
|
||||
from PySide6 import QtGui
|
||||
|
||||
from tidal_dl_ng.cache import CoverPixmapCache
|
||||
from tidal_dl_ng.download import Download
|
||||
from tidal_dl_ng.helper.path import resource_path
|
||||
from tidal_dl_ng.logger import logger_gui
|
||||
from tidal_dl_ng.worker import Worker
|
||||
|
||||
|
||||
class CoverManager:
|
||||
"""Manages cover art loading, caching and display operations."""
|
||||
|
||||
def __init__(self, parent_window, threadpool, info_tab_widget):
|
||||
"""Initialize the cover manager.
|
||||
|
||||
Args:
|
||||
parent_window: Main window instance
|
||||
threadpool: QThreadPool for async operations
|
||||
info_tab_widget: InfoTabWidget instance for display
|
||||
"""
|
||||
self.parent = parent_window
|
||||
self.threadpool = threadpool
|
||||
self.info_tab = info_tab_widget
|
||||
self.cache = CoverPixmapCache()
|
||||
self.cover_url_current = ""
|
||||
|
||||
def load_cover(self, media, use_cache_check: bool = True):
|
||||
"""Load and display cover for media item.
|
||||
|
||||
Args:
|
||||
media: Media object (Track, Album, etc.)
|
||||
use_cache_check: If True, check cache before loading
|
||||
"""
|
||||
if use_cache_check:
|
||||
# Try cache first for instant display
|
||||
cover_url = self._get_cover_url(media)
|
||||
if cover_url:
|
||||
if cover_url == self.cover_url_current:
|
||||
return # Already displayed
|
||||
|
||||
cached_pixmap = self.cache.get(cover_url)
|
||||
if cached_pixmap:
|
||||
self._display_cover(cached_pixmap, cover_url)
|
||||
return
|
||||
|
||||
# Load asynchronously
|
||||
worker = Worker(self._load_cover_async, media)
|
||||
self.threadpool.start(worker)
|
||||
|
||||
def _load_cover_async(self, media):
|
||||
"""Load cover in background thread."""
|
||||
# Emit spinner on tab widget instead of InfoTabWidget (which is QObject, not QWidget)
|
||||
tab_widget = self.info_tab.tab_widget if hasattr(self.info_tab, "tab_widget") else None
|
||||
if tab_widget:
|
||||
self.parent.s_spinner_start.emit(tab_widget)
|
||||
|
||||
try:
|
||||
cover_url = self._get_cover_url(media)
|
||||
|
||||
if cover_url and self.cover_url_current != cover_url:
|
||||
# Check cache again (thread-safe)
|
||||
cached_pixmap = self.cache.get(cover_url)
|
||||
if cached_pixmap:
|
||||
self._display_cover(cached_pixmap, cover_url)
|
||||
else:
|
||||
# Download and cache
|
||||
data_cover = Download.cover_data(cover_url)
|
||||
pixmap = QtGui.QPixmap()
|
||||
pixmap.loadFromData(data_cover)
|
||||
self.cache.set(cover_url, pixmap)
|
||||
self._display_cover(pixmap, cover_url)
|
||||
elif not cover_url:
|
||||
self._display_default_cover()
|
||||
except Exception as e:
|
||||
logger_gui.warning(f"Failed to load cover: {e}")
|
||||
self._display_default_cover()
|
||||
finally:
|
||||
self.parent.s_spinner_stop.emit()
|
||||
|
||||
def _get_cover_url(self, media) -> str | None:
|
||||
"""Extract cover URL from media object."""
|
||||
with suppress(Exception):
|
||||
if hasattr(media, "album") and media.album:
|
||||
return media.album.image()
|
||||
if hasattr(media, "image") and callable(getattr(media, "image", None)):
|
||||
return media.image()
|
||||
return None
|
||||
|
||||
def _display_cover(self, pixmap: QtGui.QPixmap, url: str):
|
||||
"""Display a pixmap on the info tab."""
|
||||
self.info_tab.set_cover_pixmap(pixmap)
|
||||
self.info_tab.cover_url_current = url
|
||||
self.cover_url_current = url
|
||||
|
||||
def _display_default_cover(self):
|
||||
"""Display default cover image."""
|
||||
path_image = resource_path("tidal_dl_ng/ui/default_album_image.png")
|
||||
pixmap = QtGui.QPixmap(path_image)
|
||||
self.info_tab.set_cover_pixmap(pixmap)
|
||||
self.info_tab.cover_url_current = ""
|
||||
self.cover_url_current = ""
|
||||
|
||||
def preload_covers_for_playlist(self, items: list) -> None:
|
||||
"""Preload cover pixmaps for a list of tracks in background.
|
||||
|
||||
Args:
|
||||
items: List of Track/Video objects to preload covers for.
|
||||
"""
|
||||
|
||||
def worker() -> None:
|
||||
# Extract unique cover URLs
|
||||
cover_urls = set()
|
||||
for item in items[:50]: # Limit to first 50
|
||||
with suppress(Exception):
|
||||
url = self._get_cover_url(item)
|
||||
if url:
|
||||
cover_urls.add(url)
|
||||
|
||||
# Preload each unique cover
|
||||
for cover_url in cover_urls:
|
||||
if self.cache.get(cover_url):
|
||||
continue # Already cached
|
||||
|
||||
with suppress(Exception):
|
||||
data_cover = Download.cover_data(cover_url)
|
||||
pixmap = QtGui.QPixmap()
|
||||
pixmap.loadFromData(data_cover)
|
||||
self.cache.set(cover_url, pixmap)
|
||||
logger_gui.debug(f"Preloaded cover: {cover_url[:50]}...")
|
||||
|
||||
worker_obj = Worker(worker)
|
||||
self.threadpool.start(worker_obj)
|
||||
|
||||
def _queue_cover_fetch(self, media):
|
||||
"""Queue a cover fetch operation for a media item."""
|
||||
with suppress(Exception):
|
||||
# previously try/except/pass
|
||||
self.threadpool.start(Worker(self.load_cover, media))
|
||||
|
||||
def _fetch_cover_pixmap(self, media, use_cache_check: bool):
|
||||
"""Fetch cover pixmap for media, with optional cache check."""
|
||||
with suppress(Exception):
|
||||
# previously try/except/continue inside loop
|
||||
pixmap = self.cache.get(self._get_cover_url(media))
|
||||
if pixmap:
|
||||
return pixmap
|
||||
|
||||
# If not in cache, download cover
|
||||
cover_url = self._get_cover_url(media)
|
||||
if cover_url:
|
||||
data_cover = Download.cover_data(cover_url)
|
||||
pixmap = QtGui.QPixmap()
|
||||
pixmap.loadFromData(data_cover)
|
||||
self.cache.set(cover_url, pixmap)
|
||||
return pixmap
|
||||
|
||||
return None
|
||||
@@ -0,0 +1,384 @@
|
||||
# tidal_dl_ng/gui_playlist.py
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from PySide6 import QtCore, QtWidgets
|
||||
from tidalapi import Album, Mix, Playlist, UserPlaylist
|
||||
from tidalapi.playlist import Folder
|
||||
|
||||
from tidal_dl_ng.constants import FAVORITES, TidalLists
|
||||
from tidal_dl_ng.helper.gui import get_user_list_media_item, set_user_list_media
|
||||
from tidal_dl_ng.helper.tidal import (
|
||||
favorite_function_factory,
|
||||
items_results_all,
|
||||
user_media_lists,
|
||||
)
|
||||
from tidal_dl_ng.logger import logger_gui
|
||||
from tidal_dl_ng.model.gui_data import StatusbarMessage
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from tidal_dl_ng.gui import MainWindow
|
||||
|
||||
|
||||
class GuiPlaylistManager:
|
||||
"""Manages the playlist, mixes, and favorites GUI and logic."""
|
||||
|
||||
def __init__(self, main_window: "MainWindow"):
|
||||
"""Initialize the playlist manager."""
|
||||
self.main_window: "MainWindow" = main_window
|
||||
self.settings = main_window.settings
|
||||
|
||||
def init_ui(self):
|
||||
"""Initialize UI elements related to playlists."""
|
||||
self._init_tree_lists(self.main_window.tr_lists_user)
|
||||
|
||||
def connect_signals(self):
|
||||
"""Connect signals for playlist-related widgets."""
|
||||
self.main_window.pb_reload_user_lists.clicked.connect(lambda: self.main_window.thread_it(self.tidal_user_lists))
|
||||
self.main_window.tr_lists_user.itemClicked.connect(self.on_list_items_show)
|
||||
self.main_window.tr_lists_user.itemExpanded.connect(self.on_tr_lists_user_expanded)
|
||||
self.main_window.tr_lists_user.customContextMenuRequested.connect(self.menu_context_tree_lists)
|
||||
self.main_window.s_populate_tree_lists.connect(self.on_populate_tree_lists)
|
||||
self.main_window.s_populate_folder_children.connect(self.on_populate_folder_children)
|
||||
|
||||
def _init_tree_lists(self, tree: QtWidgets.QTreeWidget) -> None:
|
||||
"""Initialize the user lists tree widget."""
|
||||
tree.setColumnWidth(0, 200)
|
||||
tree.setColumnHidden(1, True)
|
||||
tree.setColumnWidth(2, 300)
|
||||
tree.expandAll()
|
||||
tree.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
|
||||
|
||||
def tidal_user_lists(self) -> None:
|
||||
"""Fetch and emit user playlists, mixes, and favorites from Tidal."""
|
||||
self.main_window.s_spinner_start.emit(self.main_window.tr_lists_user)
|
||||
self.main_window.s_pb_reload_status.emit(False)
|
||||
user_all = user_media_lists(self.main_window.tidal.session)
|
||||
self.main_window.s_populate_tree_lists.emit(user_all)
|
||||
|
||||
def on_populate_tree_lists(self, user_lists: dict[str, list]) -> None:
|
||||
"""Populate the user lists tree with playlists, mixes, and favorites."""
|
||||
twi_playlists = self.main_window.tr_lists_user.findItems(TidalLists.Playlists, QtCore.Qt.MatchExactly, 0)[0]
|
||||
twi_mixes = self.main_window.tr_lists_user.findItems(TidalLists.Mixes, QtCore.Qt.MatchExactly, 0)[0]
|
||||
twi_favorites = self.main_window.tr_lists_user.findItems(TidalLists.Favorites, QtCore.Qt.MatchExactly, 0)[0]
|
||||
|
||||
for twi in [twi_playlists, twi_mixes, twi_favorites]:
|
||||
for i in reversed(range(twi.childCount())):
|
||||
twi.removeChild(twi.child(i))
|
||||
|
||||
for item in user_lists.get("playlists", []):
|
||||
if isinstance(item, Folder):
|
||||
twi_child = QtWidgets.QTreeWidgetItem(twi_playlists)
|
||||
twi_child.setText(0, f"📁 {item.name}")
|
||||
set_user_list_media(twi_child, item)
|
||||
info = f"({item.total_number_of_items} items)" if item.total_number_of_items else ""
|
||||
twi_child.setText(2, info)
|
||||
dummy_child = QtWidgets.QTreeWidgetItem(twi_child)
|
||||
dummy_child.setDisabled(True)
|
||||
elif isinstance(item, UserPlaylist | Playlist):
|
||||
twi_child = QtWidgets.QTreeWidgetItem(twi_playlists)
|
||||
name = item.name or ""
|
||||
description = f" {item.description}" if item.description else ""
|
||||
info = f"({item.num_tracks + item.num_videos} Tracks){description}"
|
||||
twi_child.setText(0, name)
|
||||
set_user_list_media(twi_child, item)
|
||||
twi_child.setText(2, info)
|
||||
|
||||
for item in user_lists.get("mixes", []):
|
||||
if isinstance(item, Mix):
|
||||
twi_child = QtWidgets.QTreeWidgetItem(twi_mixes)
|
||||
twi_child.setText(0, item.title)
|
||||
set_user_list_media(twi_child, item)
|
||||
twi_child.setText(2, item.sub_title)
|
||||
|
||||
for key, favorite in FAVORITES.items():
|
||||
twi_child = QtWidgets.QTreeWidgetItem(twi_favorites)
|
||||
twi_child.setText(0, favorite["name"])
|
||||
set_user_list_media(twi_child, key)
|
||||
|
||||
self.main_window.s_spinner_stop.emit()
|
||||
self.main_window.s_pb_reload_status.emit(True)
|
||||
|
||||
def menu_context_tree_lists(self, point: QtCore.QPoint) -> None:
|
||||
"""Show context menu for user lists tree."""
|
||||
index = self.main_window.tr_lists_user.indexAt(point)
|
||||
if not index.isValid() or not index.parent().data():
|
||||
return
|
||||
item = self.main_window.tr_lists_user.itemAt(point)
|
||||
media = get_user_list_media_item(item)
|
||||
menu = QtWidgets.QMenu()
|
||||
if isinstance(media, Folder):
|
||||
menu.addAction(
|
||||
"Download All Playlists in Folder",
|
||||
lambda: self.main_window.thread_it(self.on_download_folder_playlists, point),
|
||||
)
|
||||
menu.addAction(
|
||||
"Download All Albums from Folder",
|
||||
lambda: self.main_window.thread_it(self.on_download_folder_albums, point),
|
||||
)
|
||||
elif isinstance(media, str):
|
||||
menu.addAction("Download All Items", lambda: self.main_window.thread_it(self.on_download_favorites, point))
|
||||
menu.addAction(
|
||||
"Download All Albums from Items",
|
||||
lambda: self.main_window.thread_it(self.on_download_albums_from_favorites, point),
|
||||
)
|
||||
else:
|
||||
menu.addAction("Download Playlist", lambda: self.main_window.thread_it(self.on_download_list_media, point))
|
||||
menu.addAction(
|
||||
"Download All Albums in Playlist",
|
||||
lambda: self.main_window.thread_it(self.on_download_all_albums_from_playlist, point),
|
||||
)
|
||||
menu.addAction(
|
||||
"Copy Share URL", lambda: self.main_window.on_copy_url_share(self.main_window.tr_lists_user, point)
|
||||
)
|
||||
menu.exec_(self.main_window.tr_lists_user.mapToGlobal(point))
|
||||
|
||||
def on_download_list_media(self, point: QtCore.QPoint | None = None) -> None:
|
||||
"""Download all media items in a selected list."""
|
||||
items = (
|
||||
[self.main_window.tr_lists_user.itemAt(point)] if point else self.main_window.tr_lists_user.selectedItems()
|
||||
)
|
||||
if not items:
|
||||
logger_gui.error("Please select a mix or playlist first.")
|
||||
return
|
||||
for item in items:
|
||||
media = get_user_list_media_item(item)
|
||||
queue_dl_item = self.main_window.queue_manager.media_to_queue_download_model(media)
|
||||
if queue_dl_item:
|
||||
self.main_window.queue_manager.queue_download_media(queue_dl_item)
|
||||
|
||||
def on_download_folder_playlists(self, point: QtCore.QPoint) -> None:
|
||||
"""Download all playlists in a folder."""
|
||||
item = self.main_window.tr_lists_user.itemAt(point)
|
||||
media = get_user_list_media_item(item)
|
||||
if not isinstance(media, Folder):
|
||||
logger_gui.error("Please select a folder.")
|
||||
return
|
||||
logger_gui.info(f"Fetching playlists from folder: {media.name}")
|
||||
playlists = self._get_folder_playlists(media)
|
||||
if not playlists:
|
||||
logger_gui.info(f"No playlists found in folder: {media.name}")
|
||||
return
|
||||
logger_gui.info(f"Queueing {len(playlists)} playlists from folder: {media.name}")
|
||||
for playlist in playlists:
|
||||
queue_dl_item = self.main_window.queue_manager.media_to_queue_download_model(playlist)
|
||||
if queue_dl_item:
|
||||
self.main_window.queue_manager.queue_download_media(queue_dl_item)
|
||||
logger_gui.info(f"✅ Successfully queued {len(playlists)} playlists from folder: {media.name}")
|
||||
|
||||
def on_download_folder_albums(self, point: QtCore.QPoint) -> None:
|
||||
"""Download all unique albums from all playlists in a folder."""
|
||||
item = self.main_window.tr_lists_user.itemAt(point)
|
||||
media = get_user_list_media_item(item)
|
||||
if not isinstance(media, Folder):
|
||||
logger_gui.error("Please select a folder.")
|
||||
return
|
||||
playlists = self._get_folder_playlists(media)
|
||||
if not playlists:
|
||||
return
|
||||
all_tracks = []
|
||||
for playlist in playlists:
|
||||
try:
|
||||
tracks = self._get_playlist_tracks(playlist)
|
||||
all_tracks.extend(tracks)
|
||||
except Exception as e:
|
||||
logger_gui.error(f"Error getting tracks from playlist '{playlist.name}': {e}")
|
||||
if not all_tracks:
|
||||
return
|
||||
album_ids = self.main_window._extract_album_ids_from_tracks(all_tracks)
|
||||
if not album_ids:
|
||||
return
|
||||
albums_dict = self.main_window._load_albums_with_rate_limiting(album_ids)
|
||||
if not albums_dict:
|
||||
return
|
||||
self.main_window._queue_loaded_albums(albums_dict)
|
||||
|
||||
def on_download_favorites(self, point: QtCore.QPoint) -> None:
|
||||
"""Download all items from a Favorites category."""
|
||||
item = self.main_window.tr_lists_user.itemAt(point)
|
||||
media_key = get_user_list_media_item(item)
|
||||
if not isinstance(media_key, str):
|
||||
logger_gui.error("Please select a favorites category.")
|
||||
return
|
||||
favorite_name = FAVORITES.get(media_key, {}).get("name", media_key)
|
||||
logger_gui.info(f"Fetching all items from favorites: {favorite_name}")
|
||||
favorite_function = favorite_function_factory(self.main_window.tidal, media_key)
|
||||
media_items = favorite_function()
|
||||
if not media_items:
|
||||
logger_gui.info(f"No items found in favorites: {favorite_name}")
|
||||
return
|
||||
queued_count = 0
|
||||
for media_item in media_items:
|
||||
queue_dl_item = self.main_window.queue_manager.media_to_queue_download_model(media_item)
|
||||
if queue_dl_item:
|
||||
self.main_window.queue_manager.queue_download_media(queue_dl_item)
|
||||
queued_count += 1
|
||||
logger_gui.info(f"✅ Successfully queued {queued_count} items from favorites: {favorite_name}")
|
||||
|
||||
def _download_albums_from_fav_artists(self, media_items: list) -> None:
|
||||
"""Download all albums from favorite artists."""
|
||||
all_albums = {}
|
||||
for artist in media_items:
|
||||
try:
|
||||
artist_albums = items_results_all(self.main_window.tidal.session, artist)
|
||||
for album in artist_albums:
|
||||
if isinstance(album, Album) and album.id:
|
||||
all_albums[album.id] = album
|
||||
except Exception as e:
|
||||
logger_gui.error(f"Error getting albums from artist '{artist.name}': {e}")
|
||||
if all_albums:
|
||||
self.main_window._queue_loaded_albums(all_albums)
|
||||
|
||||
def _download_albums_from_fav_tracks(self, media_items: list) -> None:
|
||||
"""Download all albums from favorite tracks."""
|
||||
album_ids = self.main_window._extract_album_ids_from_tracks(media_items)
|
||||
if album_ids:
|
||||
albums_dict = self.main_window._load_albums_with_rate_limiting(album_ids)
|
||||
if albums_dict:
|
||||
self.main_window._queue_loaded_albums(albums_dict)
|
||||
|
||||
def on_download_albums_from_favorites(self, point: QtCore.QPoint) -> None:
|
||||
"""Download all unique albums from items in a Favorites category."""
|
||||
item = self.main_window.tr_lists_user.itemAt(point)
|
||||
media_key = get_user_list_media_item(item)
|
||||
if not isinstance(media_key, str):
|
||||
return
|
||||
favorite_function = favorite_function_factory(self.main_window.tidal, media_key)
|
||||
media_items = favorite_function()
|
||||
if not media_items:
|
||||
return
|
||||
|
||||
if media_key == "fav_albums":
|
||||
albums_dict = {album.id: album for album in media_items if isinstance(album, Album) and album.id}
|
||||
self.main_window._queue_loaded_albums(albums_dict)
|
||||
elif media_key == "fav_artists":
|
||||
self._download_albums_from_fav_artists(media_items)
|
||||
else:
|
||||
self._download_albums_from_fav_tracks(media_items)
|
||||
|
||||
def on_download_all_albums_from_playlist(self, point: QtCore.QPoint) -> None:
|
||||
"""Download all unique albums from tracks in a playlist."""
|
||||
item = self.main_window.tr_lists_user.itemAt(point)
|
||||
media_list = get_user_list_media_item(item)
|
||||
if not isinstance(media_list, Playlist | UserPlaylist | Mix):
|
||||
logger_gui.error("Please select a playlist or mix.")
|
||||
return
|
||||
media_items = items_results_all(self.main_window.tidal.session, media_list)
|
||||
album_ids = self.main_window._extract_album_ids_from_tracks(media_items)
|
||||
if not album_ids:
|
||||
logger_gui.warning("No albums found in this playlist.")
|
||||
return
|
||||
albums_dict = self.main_window._load_albums_with_rate_limiting(album_ids)
|
||||
if not albums_dict:
|
||||
logger_gui.error("Failed to load any albums from playlist.")
|
||||
return
|
||||
self.main_window._queue_loaded_albums(albums_dict)
|
||||
message = f"Added {len(albums_dict)} albums to download queue"
|
||||
self.main_window.s_statusbar_message.emit(StatusbarMessage(message=message, timeout=3000))
|
||||
|
||||
def on_tr_lists_user_expanded(self, item: QtWidgets.QTreeWidgetItem) -> None:
|
||||
"""Handle expansion of folders in the user lists tree."""
|
||||
if item.childCount() > 0 and item.child(0).isDisabled():
|
||||
self.main_window.thread_it(self.tr_lists_user_load_folder_children, item)
|
||||
|
||||
def tr_lists_user_load_folder_children(self, parent_item: QtWidgets.QTreeWidgetItem) -> None:
|
||||
"""Load and display children of a folder in the user lists tree."""
|
||||
folder = get_user_list_media_item(parent_item)
|
||||
if not isinstance(folder, Folder):
|
||||
return
|
||||
self.main_window.s_spinner_start.emit(self.main_window.tr_lists_user)
|
||||
try:
|
||||
folders, playlists = self._fetch_folder_contents(folder)
|
||||
self.main_window.s_populate_folder_children.emit(parent_item, folders, playlists)
|
||||
finally:
|
||||
self.main_window.s_spinner_stop.emit()
|
||||
|
||||
def on_populate_folder_children(
|
||||
self, parent_item: QtWidgets.QTreeWidgetItem, folders: list[Folder], playlists: list[Playlist]
|
||||
) -> None:
|
||||
"""Populate folder children in the main thread."""
|
||||
parent_item.takeChild(0)
|
||||
for sub_folder in folders:
|
||||
twi_child = QtWidgets.QTreeWidgetItem(parent_item)
|
||||
twi_child.setText(0, f"📁 {sub_folder.name}")
|
||||
set_user_list_media(twi_child, sub_folder)
|
||||
info = f"({sub_folder.total_number_of_items} items)" if sub_folder.total_number_of_items else ""
|
||||
twi_child.setText(2, info)
|
||||
dummy = QtWidgets.QTreeWidgetItem(twi_child)
|
||||
dummy.setDisabled(True)
|
||||
for playlist in playlists:
|
||||
twi_child = QtWidgets.QTreeWidgetItem(parent_item)
|
||||
twi_child.setText(0, playlist.name or "")
|
||||
set_user_list_media(twi_child, playlist)
|
||||
info = f"({playlist.num_tracks + playlist.num_videos} Tracks)"
|
||||
if playlist.description:
|
||||
info += f" {playlist.description}"
|
||||
twi_child.setText(2, info)
|
||||
|
||||
def _fetch_folder_contents(self, folder: Folder) -> tuple[list[Folder], list[Playlist]]:
|
||||
"""Fetch contents of a folder."""
|
||||
folder_id = folder.id or "root"
|
||||
folders, playlists = [], []
|
||||
offset, limit = 0, 50
|
||||
while True:
|
||||
batch = self.main_window.tidal.session.user.favorites.playlist_folders(
|
||||
limit=limit, offset=offset, parent_folder_id=folder_id
|
||||
)
|
||||
if not batch:
|
||||
break
|
||||
folders.extend(batch)
|
||||
if len(batch) < limit:
|
||||
break
|
||||
offset += limit
|
||||
offset = 0
|
||||
while True:
|
||||
batch = folder.items(offset=offset, limit=limit)
|
||||
if not batch:
|
||||
break
|
||||
playlists.extend(batch)
|
||||
if len(batch) < limit:
|
||||
break
|
||||
offset += limit
|
||||
return folders, playlists
|
||||
|
||||
def _get_folder_playlists(self, folder: Folder) -> list[Playlist]:
|
||||
"""Fetch all playlists from a folder."""
|
||||
_, playlists = self._fetch_folder_contents(folder)
|
||||
return playlists
|
||||
|
||||
def _get_playlist_tracks(self, playlist: Playlist | UserPlaylist | Mix) -> list:
|
||||
"""Fetch all tracks from a playlist."""
|
||||
return [
|
||||
item
|
||||
for item in items_results_all(self.main_window.tidal.session, playlist)
|
||||
if isinstance(item, self.main_window.tidal.session.track)
|
||||
]
|
||||
|
||||
def on_list_items_show(self, item: QtWidgets.QTreeWidgetItem) -> None:
|
||||
"""Show the items in the selected playlist or mix."""
|
||||
self.main_window.thread_it(self.list_items_show, item)
|
||||
|
||||
def list_items_show(self, item: QtWidgets.QTreeWidgetItem) -> None:
|
||||
"""Fetch and display the items in a playlist, mix, or folder."""
|
||||
media_list = get_user_list_media_item(item)
|
||||
if not media_list:
|
||||
return
|
||||
self.main_window.s_spinner_start.emit(self.main_window.tr_results)
|
||||
try:
|
||||
if isinstance(media_list, Folder):
|
||||
self._show_folder_contents(media_list)
|
||||
elif isinstance(media_list, str) and media_list.startswith("fav_"):
|
||||
function_list = favorite_function_factory(self.main_window.tidal, media_list)
|
||||
self.main_window.list_items_show_result(favorite_function=function_list)
|
||||
else:
|
||||
self.main_window.list_items_show_result(media_list)
|
||||
self.main_window.thread_it(self.main_window.cover_manager.load_cover, media_list)
|
||||
finally:
|
||||
self.main_window.s_spinner_stop.emit()
|
||||
|
||||
def _show_folder_contents(self, folder: Folder) -> None:
|
||||
"""Display folder contents in results pane."""
|
||||
folders, playlists = self._fetch_folder_contents(folder)
|
||||
items = folders + playlists
|
||||
result = self.main_window.search_manager.search_result_to_model(items)
|
||||
self.main_window.search_manager.populate_tree_results(result)
|
||||
@@ -0,0 +1,296 @@
|
||||
# tidal_dl_ng/gui_queue.py
|
||||
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from PySide6 import QtCore, QtGui, QtWidgets
|
||||
from tidalapi import Album, Artist, Mix, Playlist, Quality, Track, Video
|
||||
|
||||
from tidal_dl_ng.config import HandlingApp, Settings
|
||||
from tidal_dl_ng.constants import QualityVideo, QueueDownloadStatus
|
||||
from tidal_dl_ng.download import Download
|
||||
from tidal_dl_ng.helper.gui import (
|
||||
get_queue_download_media,
|
||||
get_queue_download_quality_audio,
|
||||
get_queue_download_quality_video,
|
||||
set_queue_download_media,
|
||||
)
|
||||
from tidal_dl_ng.helper.path import get_format_template
|
||||
from tidal_dl_ng.helper.tidal import items_results_all, name_builder_artist, name_builder_title, quality_audio_highest
|
||||
from tidal_dl_ng.logger import logger_gui
|
||||
from tidal_dl_ng.model.gui_data import QueueDownloadItem, StatusbarMessage
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from tidal_dl_ng.gui import MainWindow
|
||||
|
||||
|
||||
class GuiQueueManager:
|
||||
"""Manages the download queue GUI and logic."""
|
||||
|
||||
def __init__(self, main_window: "MainWindow"):
|
||||
"""Initialize the queue manager."""
|
||||
self.main_window: "MainWindow" = main_window
|
||||
self.settings: Settings = main_window.settings
|
||||
|
||||
def init_ui(self):
|
||||
"""Initialize UI elements related to the queue."""
|
||||
self._init_tree_queue(self.main_window.tr_queue_download)
|
||||
self.pb_queue_download_run()
|
||||
|
||||
def connect_signals(self):
|
||||
"""Connect signals for queue-related widgets."""
|
||||
self.main_window.pb_queue_download_clear_all.clicked.connect(self.on_queue_download_clear_all)
|
||||
self.main_window.pb_queue_download_clear_finished.clicked.connect(self.on_queue_download_clear_finished)
|
||||
self.main_window.pb_queue_download_remove.clicked.connect(self.on_queue_download_remove)
|
||||
self.main_window.pb_queue_download_toggle.clicked.connect(self.on_pb_queue_download_toggle)
|
||||
self.main_window.tr_queue_download.itemClicked.connect(self.on_queue_download_item_clicked)
|
||||
self.main_window.tr_queue_download.customContextMenuRequested.connect(self.menu_context_queue_download)
|
||||
self.main_window.s_queue_download_item_downloading.connect(self.on_queue_download_item_downloading)
|
||||
self.main_window.s_queue_download_item_finished.connect(self.on_queue_download_item_finished)
|
||||
self.main_window.s_queue_download_item_failed.connect(self.on_queue_download_item_failed)
|
||||
self.main_window.s_queue_download_item_skipped.connect(self.on_queue_download_item_skipped)
|
||||
|
||||
def _init_tree_queue(self, tree: QtWidgets.QTableWidget) -> None:
|
||||
"""Initialize the download queue table widget."""
|
||||
tree.setColumnHidden(1, True)
|
||||
tree.setColumnWidth(2, 200)
|
||||
header = tree.header()
|
||||
if hasattr(header, "setSectionResizeMode"):
|
||||
header.setSectionResizeMode(0, QtWidgets.QHeaderView.ResizeToContents)
|
||||
tree.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
|
||||
|
||||
def menu_context_queue_download(self, point: QtCore.QPoint) -> None:
|
||||
"""Show context menu for download queue."""
|
||||
item = self.main_window.tr_queue_download.itemAt(point)
|
||||
if not item:
|
||||
return
|
||||
menu = QtWidgets.QMenu()
|
||||
status = item.text(0)
|
||||
if status == QueueDownloadStatus.Waiting:
|
||||
menu.addAction("🗑️ Remove from Queue", lambda: self.on_queue_download_remove_item(item))
|
||||
if menu.isEmpty():
|
||||
return
|
||||
menu.exec(self.main_window.tr_queue_download.mapToGlobal(point))
|
||||
|
||||
def on_queue_download_remove_item(self, item: QtWidgets.QTreeWidgetItem) -> None:
|
||||
"""Remove a specific item from the download queue."""
|
||||
index = self.main_window.tr_queue_download.indexOfTopLevelItem(item)
|
||||
if index >= 0:
|
||||
self.main_window.tr_queue_download.takeTopLevelItem(index)
|
||||
logger_gui.info("Removed item from download queue")
|
||||
|
||||
def on_queue_download_clear_all(self) -> None:
|
||||
"""Clear all items from the download queue."""
|
||||
self.on_clear_queue_download(
|
||||
f"({QueueDownloadStatus.Waiting}|{QueueDownloadStatus.Finished}|{QueueDownloadStatus.Failed}|{QueueDownloadStatus.Skipped})"
|
||||
)
|
||||
|
||||
def on_queue_download_clear_finished(self) -> None:
|
||||
"""Clear finished items from the download queue."""
|
||||
self.on_clear_queue_download(f"({QueueDownloadStatus.Finished}|{QueueDownloadStatus.Skipped})")
|
||||
|
||||
def on_clear_queue_download(self, regex: str) -> None:
|
||||
"""Clear items from the download queue matching the given regex."""
|
||||
items = self.main_window.tr_queue_download.findItems(
|
||||
regex, QtCore.Qt.MatchFlag.MatchRegularExpression, column=0
|
||||
)
|
||||
for item in items:
|
||||
self.main_window.tr_queue_download.takeTopLevelItem(
|
||||
self.main_window.tr_queue_download.indexOfTopLevelItem(item)
|
||||
)
|
||||
|
||||
def on_queue_download_remove(self) -> None:
|
||||
"""Remove selected items from the download queue."""
|
||||
items = self.main_window.tr_queue_download.selectedItems()
|
||||
if not items:
|
||||
logger_gui.error("Please select an item from the queue first.")
|
||||
else:
|
||||
for item in items:
|
||||
status: str = item.text(0)
|
||||
if status != QueueDownloadStatus.Downloading:
|
||||
self.main_window.tr_queue_download.takeTopLevelItem(
|
||||
self.main_window.tr_queue_download.indexOfTopLevelItem(item)
|
||||
)
|
||||
else:
|
||||
logger_gui.info("Cannot remove a currently downloading item from queue.")
|
||||
|
||||
def on_pb_queue_download_toggle(self) -> None:
|
||||
"""Toggle download status (pause / resume) accordingly."""
|
||||
handling_app: HandlingApp = HandlingApp()
|
||||
if handling_app.event_run.is_set():
|
||||
self.pb_queue_download_pause()
|
||||
else:
|
||||
self.pb_queue_download_run()
|
||||
|
||||
def pb_queue_download_run(self) -> None:
|
||||
"""Start the download queue and update the button state."""
|
||||
handling_app: HandlingApp = HandlingApp()
|
||||
handling_app.event_run.set()
|
||||
icon = QtGui.QIcon(QtGui.QIcon.fromTheme(QtGui.QIcon.ThemeIcon.MediaPlaybackPause))
|
||||
self.main_window.pb_queue_download_toggle.setIcon(icon)
|
||||
self.main_window.pb_queue_download_toggle.setStyleSheet("background-color: #e0a800; color: #212529")
|
||||
|
||||
def pb_queue_download_pause(self) -> None:
|
||||
"""Pause the download queue and update the button state."""
|
||||
handling_app: HandlingApp = HandlingApp()
|
||||
handling_app.event_run.clear()
|
||||
icon = QtGui.QIcon(QtGui.QIcon.fromTheme(QtGui.QIcon.ThemeIcon.MediaPlaybackStart))
|
||||
self.main_window.pb_queue_download_toggle.setIcon(icon)
|
||||
self.main_window.pb_queue_download_toggle.setStyleSheet("background-color: #218838; color: #fff")
|
||||
|
||||
def queue_download_media(self, queue_dl_item: QueueDownloadItem) -> None:
|
||||
"""Add a media item to the download queue."""
|
||||
child = QtWidgets.QTreeWidgetItem()
|
||||
child.setText(0, queue_dl_item.status)
|
||||
set_queue_download_media(child, queue_dl_item.obj)
|
||||
child.setText(2, queue_dl_item.name)
|
||||
child.setText(3, queue_dl_item.type_media)
|
||||
child.setText(4, str(queue_dl_item.quality_audio))
|
||||
child.setText(5, str(queue_dl_item.quality_video))
|
||||
self.main_window.tr_queue_download.addTopLevelItem(child)
|
||||
|
||||
def watcher_queue_download(self) -> None:
|
||||
"""Monitor the download queue and process items as they become available."""
|
||||
handling_app: HandlingApp = HandlingApp()
|
||||
while not handling_app.event_abort.is_set():
|
||||
items = self.main_window.tr_queue_download.findItems(
|
||||
QueueDownloadStatus.Waiting, QtCore.Qt.MatchFlag.MatchExactly, column=0
|
||||
)
|
||||
if items:
|
||||
item = items[0]
|
||||
media = get_queue_download_media(item)
|
||||
quality_audio = get_queue_download_quality_audio(item)
|
||||
quality_video = get_queue_download_quality_video(item)
|
||||
try:
|
||||
self.main_window.s_queue_download_item_downloading.emit(item)
|
||||
result = self.on_queue_download(media, quality_audio=quality_audio, quality_video=quality_video)
|
||||
if result == QueueDownloadStatus.Finished:
|
||||
self.main_window.s_queue_download_item_finished.emit(item)
|
||||
elif result == QueueDownloadStatus.Skipped:
|
||||
self.main_window.s_queue_download_item_skipped.emit(item)
|
||||
except Exception as e:
|
||||
logger_gui.error(e)
|
||||
self.main_window.s_queue_download_item_failed.emit(item)
|
||||
else:
|
||||
time.sleep(2)
|
||||
|
||||
def on_queue_download_item_downloading(self, item: QtWidgets.QTreeWidgetItem) -> None:
|
||||
"""Update the status of a queue download item to 'Downloading'."""
|
||||
self.queue_download_item_status(item, QueueDownloadStatus.Downloading)
|
||||
|
||||
def on_queue_download_item_finished(self, item: QtWidgets.QTreeWidgetItem) -> None:
|
||||
"""Update the status of a queue download item to 'Finished'."""
|
||||
self.queue_download_item_status(item, QueueDownloadStatus.Finished)
|
||||
|
||||
def on_queue_download_item_failed(self, item: QtWidgets.QTreeWidgetItem) -> None:
|
||||
"""Update the status of a queue download item to 'Failed'."""
|
||||
self.queue_download_item_status(item, QueueDownloadStatus.Failed)
|
||||
|
||||
def on_queue_download_item_skipped(self, item: QtWidgets.QTreeWidgetItem) -> None:
|
||||
"""Update the status of a queue download item to 'Skipped'."""
|
||||
self.queue_download_item_status(item, QueueDownloadStatus.Skipped)
|
||||
|
||||
def queue_download_item_status(self, item: QtWidgets.QTreeWidgetItem, status: str) -> None:
|
||||
"""Set the status text of a queue download item."""
|
||||
item.setText(0, status)
|
||||
|
||||
def on_queue_download(
|
||||
self,
|
||||
media: Track | Album | Playlist | Video | Mix | Artist,
|
||||
quality_audio: Quality | None = None,
|
||||
quality_video: QualityVideo | None = None,
|
||||
) -> QueueDownloadStatus:
|
||||
"""Download the specified media item(s) and return the result status."""
|
||||
items_media = items_results_all(self.main_window.tidal.session, media) if isinstance(media, Artist) else [media]
|
||||
download_delay = bool(isinstance(media, Track | Video) and self.settings.data.download_delay)
|
||||
result = QueueDownloadStatus.Failed
|
||||
for item_media in items_media:
|
||||
result = self.download(
|
||||
item_media,
|
||||
self.main_window.dl,
|
||||
delay_track=download_delay,
|
||||
quality_audio=quality_audio,
|
||||
quality_video=quality_video,
|
||||
)
|
||||
return result
|
||||
|
||||
def download(
|
||||
self,
|
||||
media: Track | Album | Playlist | Video | Mix,
|
||||
dl: Download,
|
||||
delay_track: bool = False,
|
||||
quality_audio: Quality | None = None,
|
||||
quality_video: QualityVideo | None = None,
|
||||
) -> QueueDownloadStatus:
|
||||
"""Download a media item and return the result status."""
|
||||
self.main_window.s_pb_reset.emit()
|
||||
self.main_window.s_statusbar_message.emit(StatusbarMessage(message="Download started..."))
|
||||
file_template = get_format_template(media, self.settings)
|
||||
result_dl, path_file = False, None
|
||||
if isinstance(media, Track | Video):
|
||||
result_dl, path_file = dl.item(
|
||||
media=media,
|
||||
file_template=file_template,
|
||||
download_delay=delay_track,
|
||||
quality_audio=quality_audio,
|
||||
quality_video=quality_video,
|
||||
)
|
||||
elif isinstance(media, Album | Playlist | Mix):
|
||||
dl.items(
|
||||
media=media,
|
||||
file_template=file_template,
|
||||
video_download=self.settings.data.video_download,
|
||||
download_delay=self.settings.data.download_delay,
|
||||
quality_audio=quality_audio,
|
||||
quality_video=quality_video,
|
||||
)
|
||||
result_dl, path_file = True, "dummy"
|
||||
self.main_window.s_statusbar_message.emit(StatusbarMessage(message="Download finished.", timeout=2000))
|
||||
if result_dl and path_file:
|
||||
return QueueDownloadStatus.Finished
|
||||
if not result_dl and path_file:
|
||||
return QueueDownloadStatus.Skipped
|
||||
return QueueDownloadStatus.Failed
|
||||
|
||||
def on_queue_download_item_clicked(self, item: QtWidgets.QTreeWidgetItem) -> None:
|
||||
"""Handle the event when a queue download item is clicked."""
|
||||
media = get_queue_download_media(item)
|
||||
self.main_window.info_tab_widget.update_on_selection(media)
|
||||
self.main_window.thread_it(self.main_window.cover_manager.load_cover, media)
|
||||
|
||||
def media_to_queue_download_model(
|
||||
self, media: Artist | Track | Video | Album | Playlist | Mix
|
||||
) -> QueueDownloadItem | None:
|
||||
"""Convert a media object to a QueueDownloadItem for the download queue."""
|
||||
if hasattr(media, "available") and media.available is False:
|
||||
return None
|
||||
explicit = " 🅴" if isinstance(media, Track | Video | Album) and media.explicit else ""
|
||||
name = ""
|
||||
if isinstance(media, Track | Video):
|
||||
name = f"{name_builder_artist(media)} - {name_builder_title(media)}{explicit}"
|
||||
elif isinstance(media, Playlist | Artist):
|
||||
name = media.name
|
||||
elif isinstance(media, Album):
|
||||
name = f"{name_builder_artist(media)} - {media.name}{explicit}"
|
||||
elif isinstance(media, Mix):
|
||||
name = media.title
|
||||
|
||||
quality_audio = self.settings.data.quality_audio
|
||||
if isinstance(media, Track | Album):
|
||||
quality_highest = quality_audio_highest(media)
|
||||
if (
|
||||
self.settings.data.quality_audio == quality_highest
|
||||
or self.settings.data.quality_audio == Quality.hi_res_lossless
|
||||
):
|
||||
quality_audio = quality_highest
|
||||
|
||||
if name:
|
||||
return QueueDownloadItem(
|
||||
name=name,
|
||||
quality_audio=quality_audio,
|
||||
quality_video=self.settings.data.quality_video,
|
||||
type_media=type(media).__name__,
|
||||
status=QueueDownloadStatus.Waiting,
|
||||
obj=media,
|
||||
)
|
||||
return None
|
||||
@@ -0,0 +1,220 @@
|
||||
# tidal_dl_ng/gui_search.py
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from tidalapi import Album, Artist, Mix, Playlist, Track, Video
|
||||
from tidalapi.media import AudioMode
|
||||
from tidalapi.playlist import Folder
|
||||
from tidalapi.session import SearchTypes
|
||||
|
||||
from tidal_dl_ng.helper.tidal import (
|
||||
get_tidal_media_id,
|
||||
get_tidal_media_type,
|
||||
instantiate_media,
|
||||
name_builder_artist,
|
||||
quality_audio_highest,
|
||||
search_results_all,
|
||||
url_ending_clean,
|
||||
)
|
||||
from tidal_dl_ng.logger import logger_gui
|
||||
from tidal_dl_ng.model.gui_data import ResultItem
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from tidal_dl_ng.gui import MainWindow
|
||||
|
||||
|
||||
class GuiSearchManager:
|
||||
"""Manages the search GUI and logic."""
|
||||
|
||||
def __init__(self, main_window: "MainWindow"):
|
||||
"""Initialize the search manager."""
|
||||
self.main_window: "MainWindow" = main_window
|
||||
|
||||
def search_populate_results(self, query: str, type_media: Any) -> None:
|
||||
"""Populate the results tree with search results."""
|
||||
results = self.search(query, [type_media])
|
||||
self.main_window.populate_tree_results(results)
|
||||
|
||||
def search(self, query: str, types_media: list[Any]) -> list[ResultItem]:
|
||||
"""Perform a search and return a list of ResultItems."""
|
||||
query_clean = query.strip()
|
||||
if "http" in query_clean:
|
||||
query_clean = url_ending_clean(query_clean)
|
||||
media_type = get_tidal_media_type(query_clean)
|
||||
item_id = get_tidal_media_id(query_clean)
|
||||
try:
|
||||
media = instantiate_media(self.main_window.tidal.session, media_type, item_id)
|
||||
result_search = {"direct": [media]}
|
||||
except Exception:
|
||||
logger_gui.error(f"Media not found (ID: {item_id}). Maybe it is not available anymore.")
|
||||
result_search = {"direct": []}
|
||||
else:
|
||||
result_search = search_results_all(
|
||||
session=self.main_window.tidal.session, needle=query_clean, types_media=types_media
|
||||
)
|
||||
result = []
|
||||
for _media_type, l_media in result_search.items():
|
||||
if isinstance(l_media, list):
|
||||
result.extend(self.search_result_to_model(l_media))
|
||||
return result
|
||||
|
||||
def search_result_to_model(self, items: list[SearchTypes]) -> list[ResultItem]:
|
||||
"""Convert search results to ResultItem models."""
|
||||
return [self._to_result_item(idx, item) for idx, item in enumerate(items) if self._to_result_item(idx, item)]
|
||||
|
||||
def _to_result_item(self, idx: int, item: Any) -> ResultItem | None:
|
||||
"""Helper to convert a single item to ResultItem."""
|
||||
if not item or (hasattr(item, "available") and not item.available):
|
||||
return None
|
||||
|
||||
explicit = " 🅴" if isinstance(item, Track | Video | Album) and item.explicit else ""
|
||||
date_user_added = (
|
||||
item.user_date_added.strftime("%Y-%m-%d_%H:%M") if getattr(item, "user_date_added", None) else ""
|
||||
)
|
||||
date_release = self._get_date_release(item)
|
||||
|
||||
type_handlers = {
|
||||
Track: self._result_item_from_track,
|
||||
Video: self._result_item_from_video,
|
||||
Playlist: self._result_item_from_playlist,
|
||||
Album: self._result_item_from_album,
|
||||
Mix: self._result_item_from_mix,
|
||||
Artist: self._result_item_from_artist,
|
||||
Folder: self._result_item_from_folder,
|
||||
}
|
||||
|
||||
for item_type, handler in type_handlers.items():
|
||||
if isinstance(item, item_type):
|
||||
return handler(idx, item, explicit, date_user_added, date_release)
|
||||
return None
|
||||
|
||||
def _get_date_release(self, item: Any) -> str:
|
||||
"""Get the release date string for an item."""
|
||||
if hasattr(item, "album") and item.album and getattr(item.album, "release_date", None):
|
||||
return item.album.release_date.strftime("%Y-%m-%d_%H:%M")
|
||||
if hasattr(item, "release_date") and item.release_date:
|
||||
return item.release_date.strftime("%Y-%m-%d_%H:%M")
|
||||
return ""
|
||||
|
||||
def _result_item_from_track(
|
||||
self, idx: int, item: Track, explicit: str, date_user_added: str, date_release: str
|
||||
) -> ResultItem:
|
||||
"""Create a ResultItem from a Track."""
|
||||
final_quality = quality_audio_highest(item)
|
||||
if hasattr(item, "audio_modes") and AudioMode.dolby_atmos.value in item.audio_modes:
|
||||
final_quality = f"{final_quality} / Dolby Atmos"
|
||||
return ResultItem(
|
||||
position=idx,
|
||||
artist=name_builder_artist(item),
|
||||
title=f"{item.name}{explicit}",
|
||||
album=item.album.name,
|
||||
duration_sec=item.duration,
|
||||
obj=item,
|
||||
quality=final_quality,
|
||||
explicit=bool(item.explicit),
|
||||
date_user_added=date_user_added,
|
||||
date_release=date_release,
|
||||
)
|
||||
|
||||
def _result_item_from_video(
|
||||
self, idx: int, item: Video, explicit: str, date_user_added: str, date_release: str
|
||||
) -> ResultItem:
|
||||
"""Create a ResultItem from a Video."""
|
||||
return ResultItem(
|
||||
position=idx,
|
||||
artist=name_builder_artist(item),
|
||||
title=f"{item.name}{explicit}",
|
||||
album=item.album.name if item.album else "",
|
||||
duration_sec=item.duration,
|
||||
obj=item,
|
||||
quality=item.video_quality,
|
||||
explicit=bool(item.explicit),
|
||||
date_user_added=date_user_added,
|
||||
date_release=date_release,
|
||||
)
|
||||
|
||||
def _result_item_from_playlist(
|
||||
self, idx: int, item: Playlist, explicit: str, date_user_added: str, date_release: str
|
||||
) -> ResultItem:
|
||||
"""Create a ResultItem from a Playlist."""
|
||||
return ResultItem(
|
||||
position=idx,
|
||||
artist=", ".join(artist.name for artist in item.promoted_artists) if item.promoted_artists else "",
|
||||
title=item.name,
|
||||
album="",
|
||||
duration_sec=item.duration,
|
||||
obj=item,
|
||||
quality="",
|
||||
explicit=False,
|
||||
date_user_added=date_user_added,
|
||||
date_release=date_release,
|
||||
)
|
||||
|
||||
def _result_item_from_album(
|
||||
self, idx: int, item: Album, explicit: str, date_user_added: str, date_release: str
|
||||
) -> ResultItem:
|
||||
"""Create a ResultItem from an Album."""
|
||||
return ResultItem(
|
||||
position=idx,
|
||||
artist=name_builder_artist(item),
|
||||
title="",
|
||||
album=f"{item.name}{explicit}",
|
||||
duration_sec=item.duration,
|
||||
obj=item,
|
||||
quality=quality_audio_highest(item),
|
||||
explicit=bool(item.explicit),
|
||||
date_user_added=date_user_added,
|
||||
date_release=date_release,
|
||||
)
|
||||
|
||||
def _result_item_from_mix(
|
||||
self, idx: int, item: Mix, explicit: str, date_user_added: str, date_release: str
|
||||
) -> ResultItem:
|
||||
"""Create a ResultItem from a Mix."""
|
||||
return ResultItem(
|
||||
position=idx,
|
||||
artist=item.sub_title,
|
||||
title=item.title,
|
||||
album="",
|
||||
duration_sec=-1,
|
||||
obj=item,
|
||||
quality="",
|
||||
explicit=False,
|
||||
date_user_added=date_user_added,
|
||||
date_release=date_release,
|
||||
)
|
||||
|
||||
def _result_item_from_artist(
|
||||
self, idx: int, item: Artist, explicit: str, date_user_added: str, date_release: str
|
||||
) -> ResultItem:
|
||||
"""Create a ResultItem from an Artist."""
|
||||
return ResultItem(
|
||||
position=idx,
|
||||
artist=item.name,
|
||||
title="",
|
||||
album="",
|
||||
duration_sec=-1,
|
||||
obj=item,
|
||||
quality="",
|
||||
explicit=False,
|
||||
date_user_added=date_user_added,
|
||||
date_release=date_release,
|
||||
)
|
||||
|
||||
def _result_item_from_folder(
|
||||
self, idx: int, item: Folder, explicit: str, date_user_added: str, date_release: str
|
||||
) -> ResultItem:
|
||||
"""Create a ResultItem from a Folder."""
|
||||
total_items = item.total_number_of_items if hasattr(item, "total_number_of_items") else 0
|
||||
return ResultItem(
|
||||
position=idx,
|
||||
artist="",
|
||||
title=f"📁 {item.name} ({total_items} items)",
|
||||
album="",
|
||||
duration_sec=-1,
|
||||
obj=item,
|
||||
quality="",
|
||||
explicit=False,
|
||||
date_user_added=date_user_added,
|
||||
date_release=date_release,
|
||||
)
|
||||
@@ -0,0 +1,187 @@
|
||||
"""Hover Event Manager with Debounce Logic.
|
||||
|
||||
This module provides a robust hover event management system for the track list,
|
||||
implementing debouncing to prevent UI flickering and excessive updates during
|
||||
rapid mouse movements.
|
||||
|
||||
The HoverManager intercepts mouse movement events and triggers callbacks only
|
||||
after the mouse has remained stationary over an item for a configurable delay.
|
||||
|
||||
Architecture:
|
||||
- HoverManager: Main coordinator with debounce timer
|
||||
- Event filter for capturing mouse movements
|
||||
- Signal-based callbacks for decoupled communication
|
||||
|
||||
Performance:
|
||||
- Configurable debounce delay (default: 350ms)
|
||||
- Automatic cleanup of pending timers
|
||||
- Thread-safe signal emissions
|
||||
"""
|
||||
|
||||
from PySide6 import QtCore, QtGui, QtWidgets
|
||||
from tidalapi import Album, Mix, Playlist, Track, Video
|
||||
from tidalapi.artist import Artist
|
||||
|
||||
from tidal_dl_ng.helper.gui import get_results_media_item
|
||||
from tidal_dl_ng.logger import logger_gui
|
||||
|
||||
|
||||
class HoverManager(QtCore.QObject):
|
||||
"""Manages hover events with debouncing to prevent UI flickering.
|
||||
|
||||
This class implements an event filter that intercepts mouse movements over
|
||||
a QTreeView and emits signals after a configurable delay to prevent rapid
|
||||
updates when the user is just scanning through the list.
|
||||
|
||||
Signals:
|
||||
s_hover_confirmed (object): Emitted when hover is confirmed after debounce delay.
|
||||
s_hover_left: Emitted when mouse leaves the tracked widget.
|
||||
|
||||
Attributes:
|
||||
debounce_delay_ms (int): Delay in milliseconds before confirming hover.
|
||||
tree_view (QTreeView): The tree view to monitor.
|
||||
proxy_model (QSortFilterProxyModel): Proxy model for the tree view.
|
||||
source_model (QStandardItemModel): Source model for the tree view.
|
||||
debounce_timer (QTimer): Timer for debouncing hover events.
|
||||
last_hovered_media (Track | Video | Album | None): Last media item hovered.
|
||||
"""
|
||||
|
||||
# Signals
|
||||
s_hover_confirmed: QtCore.Signal = QtCore.Signal(object) # Emits media object
|
||||
s_hover_left: QtCore.Signal = QtCore.Signal() # Emits when hover leaves
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
tree_view: QtWidgets.QTreeView,
|
||||
proxy_model: QtCore.QSortFilterProxyModel,
|
||||
source_model: QtGui.QStandardItemModel,
|
||||
debounce_delay_ms: int = 350,
|
||||
parent: QtCore.QObject | None = None,
|
||||
) -> None:
|
||||
"""Initialize the HoverManager.
|
||||
|
||||
Args:
|
||||
tree_view (QTreeView): The tree view to monitor for hover events.
|
||||
proxy_model (QSortFilterProxyModel): Proxy model wrapping the source model.
|
||||
source_model (QStandardItemModel): Source model containing the data.
|
||||
debounce_delay_ms (int, optional): Debounce delay in milliseconds. Defaults to 350.
|
||||
parent (QObject | None, optional): Parent QObject. Defaults to None.
|
||||
"""
|
||||
super().__init__(parent)
|
||||
|
||||
self.debounce_delay_ms: int = debounce_delay_ms
|
||||
self.tree_view: QtWidgets.QTreeView = tree_view
|
||||
self.proxy_model: QtCore.QSortFilterProxyModel = proxy_model
|
||||
self.source_model: QtGui.QStandardItemModel = source_model
|
||||
self.last_hovered_media: Track | Video | Album | Mix | Playlist | Artist | None = None
|
||||
|
||||
# Debounce timer (single-shot)
|
||||
self.debounce_timer: QtCore.QTimer = QtCore.QTimer(self)
|
||||
self.debounce_timer.setSingleShot(True)
|
||||
self.debounce_timer.setInterval(self.debounce_delay_ms)
|
||||
self.debounce_timer.timeout.connect(self._on_debounce_timeout)
|
||||
|
||||
# Track hover state
|
||||
self.pending_media: Track | Video | Album | Mix | Playlist | Artist | None = None
|
||||
|
||||
# Install event filter on viewport (where mouse events occur)
|
||||
self.tree_view.viewport().installEventFilter(self)
|
||||
self.tree_view.viewport().setMouseTracking(True)
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop the hover manager and clean up event filters."""
|
||||
try:
|
||||
if self.tree_view:
|
||||
self.tree_view.viewport().removeEventFilter(self)
|
||||
self.tree_view = None
|
||||
except RuntimeError:
|
||||
pass
|
||||
if self.timer and self.timer.isActive():
|
||||
self.timer.stop()
|
||||
|
||||
def eventFilter(self, watched: QtCore.QObject, event: QtCore.QEvent) -> bool:
|
||||
"""Filter events to detect mouse movements over tree items.
|
||||
|
||||
Args:
|
||||
watched (QObject): The object being watched.
|
||||
event (QEvent): The event to filter.
|
||||
|
||||
Returns:
|
||||
bool: True if event is handled, False otherwise.
|
||||
"""
|
||||
if watched == self.tree_view.viewport():
|
||||
if event.type() == QtCore.QEvent.Type.MouseMove:
|
||||
# Cast to QMouseEvent for type safety
|
||||
mouse_event = QtGui.QMouseEvent(event)
|
||||
self._handle_mouse_move(mouse_event)
|
||||
elif event.type() == QtCore.QEvent.Type.Leave:
|
||||
self._handle_mouse_leave()
|
||||
|
||||
# Don't consume the event - let it propagate
|
||||
return False
|
||||
|
||||
def _handle_mouse_move(self, event: QtGui.QMouseEvent) -> None:
|
||||
"""Handle mouse move events over the tree view.
|
||||
|
||||
Args:
|
||||
event (QMouseEvent): The mouse move event.
|
||||
"""
|
||||
# Get the index under the cursor
|
||||
pos = event.position().toPoint()
|
||||
index = self.tree_view.indexAt(pos)
|
||||
|
||||
if not index.isValid():
|
||||
# Mouse is not over a valid item
|
||||
self._cancel_pending_hover()
|
||||
return
|
||||
|
||||
try:
|
||||
# Extract media from the index
|
||||
media = get_results_media_item(index, self.proxy_model, self.source_model)
|
||||
|
||||
if not media:
|
||||
self._cancel_pending_hover()
|
||||
return
|
||||
|
||||
# Check if we're hovering over a different item
|
||||
if media != self.pending_media:
|
||||
# Cancel previous timer and start new one
|
||||
self.pending_media = media
|
||||
self.debounce_timer.stop()
|
||||
self.debounce_timer.start()
|
||||
|
||||
except Exception as e:
|
||||
logger_gui.debug(f"Error extracting media from hover: {e}")
|
||||
self._cancel_pending_hover()
|
||||
|
||||
def _handle_mouse_leave(self) -> None:
|
||||
"""Handle mouse leaving the tree view."""
|
||||
self._cancel_pending_hover()
|
||||
self.s_hover_left.emit()
|
||||
|
||||
def _cancel_pending_hover(self) -> None:
|
||||
"""Cancel any pending hover confirmation."""
|
||||
self.debounce_timer.stop()
|
||||
self.pending_media = None
|
||||
|
||||
def _on_debounce_timeout(self) -> None:
|
||||
"""Handle debounce timer timeout - hover is confirmed."""
|
||||
if self.pending_media:
|
||||
self.last_hovered_media = self.pending_media
|
||||
self.s_hover_confirmed.emit(self.pending_media)
|
||||
# logger_gui.debug(f"Hover confirmed: {getattr(self.pending_media, 'name', 'Unknown')}")
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset the hover manager state."""
|
||||
self._cancel_pending_hover()
|
||||
self.last_hovered_media = None
|
||||
|
||||
def set_debounce_delay(self, delay_ms: int) -> None:
|
||||
"""Change the debounce delay.
|
||||
|
||||
Args:
|
||||
delay_ms (int): New delay in milliseconds.
|
||||
"""
|
||||
self.debounce_delay_ms = delay_ms
|
||||
self.debounce_timer.setInterval(delay_ms)
|
||||
logger_gui.debug(f"Hover debounce delay set to {delay_ms}ms")
|
||||
@@ -0,0 +1,227 @@
|
||||
"""Utilities for extracting and formatting metadata from TIDAL objects."""
|
||||
|
||||
from contextlib import suppress
|
||||
|
||||
|
||||
def _convert_list_to_str(value: list | tuple) -> str:
|
||||
"""Convert list/tuple to comma-separated string."""
|
||||
if not value:
|
||||
return "—"
|
||||
with suppress(Exception):
|
||||
return ", ".join([str(x) for x in value])
|
||||
return str(value)
|
||||
|
||||
|
||||
def _convert_dict_to_str(value: dict) -> str:
|
||||
"""Extract meaningful string from dict."""
|
||||
if "name" in value and value["name"]:
|
||||
return str(value["name"])
|
||||
for k in ("label", "title", "genre", "name"):
|
||||
if k in value and value[k]:
|
||||
return str(value[k])
|
||||
with suppress(Exception):
|
||||
vals = [str(v) for v in value.values() if v is not None]
|
||||
if vals:
|
||||
return ", ".join(vals)
|
||||
return str(value)
|
||||
|
||||
|
||||
def safe_str(value: object) -> str:
|
||||
"""Convert a potentially non-str value into a safe string for display.
|
||||
|
||||
Args:
|
||||
value: The value to convert.
|
||||
|
||||
Returns:
|
||||
str: Safe string representation, '—' for None/empty.
|
||||
"""
|
||||
with suppress(Exception):
|
||||
if value is None:
|
||||
return "—"
|
||||
if isinstance(value, str):
|
||||
return value if value != "" else "—"
|
||||
if isinstance(value, list | tuple):
|
||||
return _convert_list_to_str(value)
|
||||
if isinstance(value, dict):
|
||||
return _convert_dict_to_str(value)
|
||||
return str(value)
|
||||
return "—"
|
||||
|
||||
|
||||
def _find_in_dict_container(container: dict, names: tuple[str, ...]) -> object | None:
|
||||
"""Search for names in a dict container."""
|
||||
for n in names:
|
||||
if n in container and container[n] is not None:
|
||||
return container[n]
|
||||
# Fuzzy key match
|
||||
keys = list(container.keys())
|
||||
for n in names:
|
||||
for k in keys:
|
||||
if n.lower() in str(k).lower():
|
||||
return container[k]
|
||||
return None
|
||||
|
||||
|
||||
def _fuzzy_scan_attrs(obj: object, names: tuple[str, ...]) -> object | None:
|
||||
"""Fuzzy scan object attributes for matching names."""
|
||||
with suppress(Exception):
|
||||
for k in dir(obj):
|
||||
kl = k.lower()
|
||||
for n in names:
|
||||
if n.lower() in kl:
|
||||
with suppress(Exception):
|
||||
val = getattr(obj, k)
|
||||
if val is not None:
|
||||
return val
|
||||
return None
|
||||
|
||||
|
||||
def find_attr(obj: object, *names: str) -> object | None:
|
||||
"""Attempt to find an attribute or data key from an object or its internals.
|
||||
|
||||
Args:
|
||||
obj: The object to inspect.
|
||||
*names: Attribute/key names to search for.
|
||||
|
||||
Returns:
|
||||
The found value or None.
|
||||
"""
|
||||
# Direct attributes
|
||||
for n in names:
|
||||
with suppress(Exception):
|
||||
if hasattr(obj, n):
|
||||
val = getattr(obj, n)
|
||||
if val is not None:
|
||||
return val
|
||||
|
||||
# Inspect common dict-like internals
|
||||
for container_name in ("_data", "data", "__dict__"):
|
||||
with suppress(Exception):
|
||||
container = getattr(obj, container_name, None)
|
||||
if isinstance(container, dict):
|
||||
result = _find_in_dict_container(container, names)
|
||||
if result is not None:
|
||||
return result
|
||||
|
||||
# Fuzzy scan of attributes
|
||||
return _fuzzy_scan_attrs(obj, names)
|
||||
|
||||
|
||||
def _scan_dict_recursive(container: dict, key_substrings: list[str]) -> object | None:
|
||||
"""Recursively scan a dict for keys matching any substring."""
|
||||
for k, v in container.items():
|
||||
kl = str(k).lower()
|
||||
for s in key_substrings:
|
||||
if s.lower() in kl and v is not None:
|
||||
return v
|
||||
# recurse into nested structures
|
||||
if isinstance(v, dict):
|
||||
found = _scan_dict_recursive(v, key_substrings)
|
||||
if found is not None:
|
||||
return found
|
||||
if isinstance(v, list | tuple):
|
||||
for item in v:
|
||||
if isinstance(item, dict):
|
||||
found = _scan_dict_recursive(item, key_substrings)
|
||||
if found is not None:
|
||||
return found
|
||||
return None
|
||||
|
||||
|
||||
def search_in_data(obj: object, key_substrings: list[str]) -> object | None:
|
||||
"""Recursively search dict-like internals for keys containing any substring.
|
||||
|
||||
Args:
|
||||
obj: The object to search.
|
||||
key_substrings: List of key substrings to search for.
|
||||
|
||||
Returns:
|
||||
The first matching value found, or None.
|
||||
"""
|
||||
# check common containers
|
||||
for container_name in ("_data", "data", "__dict__"):
|
||||
with suppress(Exception):
|
||||
container = getattr(obj, container_name, None)
|
||||
if isinstance(container, dict):
|
||||
found = _scan_dict_recursive(container, key_substrings)
|
||||
if found is not None:
|
||||
return found
|
||||
|
||||
# as last resort, try obj.__dict__ if available
|
||||
with suppress(Exception):
|
||||
d = getattr(obj, "__dict__", None)
|
||||
if isinstance(d, dict):
|
||||
return _scan_dict_recursive(d, key_substrings)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _extract_name_from_dict(item: dict, match_types: tuple[str, ...] | None) -> str | None:
|
||||
"""Extract name from a dict if it matches type filters."""
|
||||
if "name" not in item or not item["name"]:
|
||||
return None
|
||||
if match_types:
|
||||
t = item.get("type") or item.get("role") or item.get("credit_type")
|
||||
if t and any(mt in str(t).lower() for mt in match_types):
|
||||
return str(item["name"])
|
||||
return None
|
||||
return str(item["name"])
|
||||
|
||||
|
||||
def _extract_name_from_item(item: object) -> str | None:
|
||||
"""Extract name from various item formats."""
|
||||
if item is None:
|
||||
return None
|
||||
if isinstance(item, str):
|
||||
return item
|
||||
if isinstance(item, dict):
|
||||
# Try common name keys
|
||||
for k in ("name", "artist", "person"):
|
||||
if k in item and item[k]:
|
||||
return str(item[k])
|
||||
return None
|
||||
# Try object attributes
|
||||
nm = getattr(item, "name", None) or getattr(item, "title", None)
|
||||
if nm:
|
||||
return str(nm)
|
||||
with suppress(Exception):
|
||||
return str(item)
|
||||
return None
|
||||
|
||||
|
||||
def extract_names_from_mixed(value: object, match_types: tuple[str, ...] | None = None) -> list[str]:
|
||||
"""Normalize various credit-like structures into a list of names.
|
||||
|
||||
Accepts lists of dicts, dicts, strings, or objects.
|
||||
If match_types provided, only include entries where the type/role matches one of them.
|
||||
|
||||
Args:
|
||||
value: The value to extract names from.
|
||||
match_types: Optional tuple of role types to filter by.
|
||||
|
||||
Returns:
|
||||
List of extracted names.
|
||||
"""
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, str):
|
||||
return [value]
|
||||
if isinstance(value, dict):
|
||||
name = _extract_name_from_dict(value, match_types)
|
||||
if name:
|
||||
return [name]
|
||||
vals = [str(v) for v in value.values() if v is not None]
|
||||
return vals
|
||||
if isinstance(value, list | tuple):
|
||||
names: list[str] = []
|
||||
for item in value:
|
||||
if isinstance(item, dict):
|
||||
name = _extract_name_from_dict(item, match_types)
|
||||
if name:
|
||||
names.append(name)
|
||||
else:
|
||||
name = _extract_name_from_item(item)
|
||||
if name:
|
||||
names.append(name)
|
||||
return names
|
||||
return []
|
||||
+335
-5
@@ -1,5 +1,8 @@
|
||||
import contextlib
|
||||
import os
|
||||
from collections.abc import Callable
|
||||
|
||||
import requests
|
||||
from tidalapi import Album, Mix, Playlist, Session, Track, UserPlaylist, Video
|
||||
from tidalapi.artist import Artist, Role
|
||||
from tidalapi.media import MediaMetadataTags, Quality
|
||||
@@ -135,7 +138,7 @@ def search_results_all(session: Session, needle: str, types_media: SearchTypes =
|
||||
|
||||
|
||||
def items_results_all(
|
||||
media_list: [Mix | Playlist | Album | Artist], videos_include: bool = True
|
||||
session: Session, media_list: [Mix | Playlist | Album | Artist], videos_include: bool = True
|
||||
) -> [Track | Video | Album]:
|
||||
result: [Track | Video | Album] = []
|
||||
|
||||
@@ -149,7 +152,7 @@ def items_results_all(
|
||||
func_get_items_media.append(media_list.items)
|
||||
else:
|
||||
func_get_items_media.append(media_list.tracks)
|
||||
else:
|
||||
elif isinstance(media_list, Artist):
|
||||
func_get_items_media.append(media_list.get_albums)
|
||||
func_get_items_media.append(media_list.get_ep_singles)
|
||||
|
||||
@@ -257,12 +260,20 @@ def instantiate_media(
|
||||
def quality_audio_highest(media: Track | Album) -> Quality:
|
||||
quality: Quality
|
||||
|
||||
if MediaMetadataTags.hi_res_lossless in media.media_metadata_tags:
|
||||
# media_metadata_tags may be missing (Mock objects) or None; use safe getter
|
||||
tags = getattr(media, "media_metadata_tags", None)
|
||||
try:
|
||||
iterable_tags = set(tags) if tags is not None else set()
|
||||
except Exception:
|
||||
# If tags is a Mock or non-iterable, fall back to empty set
|
||||
iterable_tags = set()
|
||||
|
||||
if MediaMetadataTags.hi_res_lossless in iterable_tags:
|
||||
quality = Quality.hi_res_lossless
|
||||
elif MediaMetadataTags.lossless in media.media_metadata_tags:
|
||||
elif MediaMetadataTags.lossless in iterable_tags:
|
||||
quality = Quality.high_lossless
|
||||
else:
|
||||
quality = media.audio_quality
|
||||
quality = getattr(media, "audio_quality", Quality.low_320k)
|
||||
|
||||
return quality
|
||||
|
||||
@@ -272,3 +283,322 @@ def favorite_function_factory(tidal, favorite_item: str):
|
||||
function_list: Callable = getattr(tidal.session.user.favorites, function_name)
|
||||
|
||||
return function_list
|
||||
|
||||
|
||||
def fetch_raw_media_json(
|
||||
session: Session, media_type: str, media_id: str, country_code: str | None = None, extra_params: dict | None = None
|
||||
) -> dict | None:
|
||||
"""Fetch raw JSON for a media resource using tidalapi's session.request.
|
||||
|
||||
Args:
|
||||
session (Session): the tidalapi Session
|
||||
media_type (str): 'tracks' or 'albums'
|
||||
media_id (str): id of media
|
||||
country_code (str | None): optional countryCode param
|
||||
|
||||
Returns:
|
||||
dict | None: parsed JSON or None if fetch fails
|
||||
"""
|
||||
try:
|
||||
params = {}
|
||||
# If caller didn't provide a country code, check environment variable
|
||||
cc = country_code or os.environ.get("TIDAL_COUNTRY")
|
||||
if cc:
|
||||
params["countryCode"] = cc
|
||||
# merge extra params if provided (do not overwrite existing keys unless provided)
|
||||
if extra_params and isinstance(extra_params, dict):
|
||||
for k, v in extra_params.items():
|
||||
params[k] = v
|
||||
|
||||
# Use session.request.request to call the internal API endpoint
|
||||
resp = session.request.request("GET", f"{media_type}/{media_id}", params=params)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
except requests.exceptions.HTTPError:
|
||||
return None # Silently ignore HTTP errors
|
||||
except Exception:
|
||||
return None # Silently ignore other errors
|
||||
|
||||
|
||||
def fetch_raw_track_and_album(
|
||||
session: Session,
|
||||
track_id: str,
|
||||
country_code: str | None = None,
|
||||
extra_params: dict | None = None,
|
||||
) -> tuple[dict | None, dict | None]:
|
||||
"""Convenience to fetch raw track JSON and its album JSON (if available).
|
||||
|
||||
Returns a tuple (track_json, album_json).
|
||||
|
||||
Uses TIDAL API parameters to fetch extended metadata.
|
||||
Note: 'credits' and 'contributors' are NOT available in TIDAL API v2.
|
||||
Available include values: albums, artists, genres, lyrics, owners, providers,
|
||||
radio, shares, similarTracks, sourceFile, trackStatistics
|
||||
"""
|
||||
# Use valid include parameters according to TIDAL API v2 spec
|
||||
# https://tidal-music.github.io/tidal-api-reference/
|
||||
default_track_params = {
|
||||
"include": "albums,artists,genres", # Valid parameters per API spec
|
||||
}
|
||||
merged_track_params = {**default_track_params, **(extra_params or {})}
|
||||
|
||||
track_json = fetch_raw_media_json(
|
||||
session,
|
||||
"tracks",
|
||||
str(track_id),
|
||||
country_code=country_code,
|
||||
extra_params=merged_track_params,
|
||||
)
|
||||
|
||||
album_json = None
|
||||
try:
|
||||
if isinstance(track_json, dict):
|
||||
album = track_json.get("album")
|
||||
album_id = album.get("id") if isinstance(album, dict) else album
|
||||
if album_id:
|
||||
# Request extended album metadata
|
||||
default_album_params = {
|
||||
"include": "artists,genres", # Valid parameters for albums
|
||||
}
|
||||
merged_album_params = {**default_album_params, **(extra_params or {})}
|
||||
|
||||
album_json = fetch_raw_media_json(
|
||||
session,
|
||||
"albums",
|
||||
str(album_id),
|
||||
country_code=country_code,
|
||||
extra_params=merged_album_params,
|
||||
)
|
||||
except Exception:
|
||||
album_json = None
|
||||
|
||||
return track_json, album_json
|
||||
|
||||
|
||||
def _normalize_dict_contributors(raw_contributors: dict) -> dict[str, list[str]]:
|
||||
"""Process contributors in dict format: role -> list[{name, ...}]."""
|
||||
result: dict[str, list[str]] = {}
|
||||
for role, people in raw_contributors.items():
|
||||
if not isinstance(people, list):
|
||||
continue
|
||||
names: list[str] = []
|
||||
for person in people:
|
||||
if isinstance(person, dict):
|
||||
name = person.get("name")
|
||||
if isinstance(name, str) and name:
|
||||
names.append(name)
|
||||
if names:
|
||||
result[role] = names
|
||||
return result
|
||||
|
||||
|
||||
def _normalize_list_contributors(raw_contributors: list) -> dict[str, list[str]]:
|
||||
"""Process contributors in list format: [{name, role, ...}, ...]."""
|
||||
result: dict[str, list[str]] = {}
|
||||
for person in raw_contributors:
|
||||
if not isinstance(person, dict):
|
||||
continue
|
||||
name = person.get("name")
|
||||
role = person.get("role")
|
||||
if isinstance(name, str) and name and isinstance(role, str) and role:
|
||||
result.setdefault(role, []).append(name)
|
||||
return result
|
||||
|
||||
|
||||
def _normalize_contributors(raw_contributors: object) -> dict[str, list[str]]:
|
||||
"""Normalize various possible contributor JSON shapes into role -> list[str] names.
|
||||
|
||||
The TIDAL API has used at least two shapes historically:
|
||||
- dict role -> list[ {"name": str, ...} ]
|
||||
- list[ {"name": str, "role": str, ...} ]
|
||||
|
||||
We accept both and ignore malformed entries.
|
||||
"""
|
||||
if isinstance(raw_contributors, dict):
|
||||
return _normalize_dict_contributors(raw_contributors)
|
||||
if isinstance(raw_contributors, list):
|
||||
return _normalize_list_contributors(raw_contributors)
|
||||
return {}
|
||||
|
||||
|
||||
def _extract_bpm_from_track(track_json: dict) -> int | None:
|
||||
"""Extract BPM from track JSON."""
|
||||
bpm = track_json.get("bpm")
|
||||
if isinstance(bpm, int | float):
|
||||
return int(round(bpm))
|
||||
if isinstance(bpm, str):
|
||||
with contextlib.suppress(ValueError):
|
||||
return int(round(float(bpm)))
|
||||
return None
|
||||
|
||||
|
||||
def _process_credits_contributors(credits_list: list) -> dict[str, list[str]]:
|
||||
"""Process credits API v2 format and return contributors by role."""
|
||||
role_mapping = {
|
||||
"producer": "producer",
|
||||
"producers": "producer",
|
||||
"composer": "composer",
|
||||
"composers": "composer",
|
||||
"lyricist": "lyricist",
|
||||
"lyricists": "lyricist",
|
||||
"writer": "composer",
|
||||
"writers": "composer",
|
||||
}
|
||||
result: dict[str, list[str]] = {}
|
||||
for credit in credits_list:
|
||||
if not isinstance(credit, dict):
|
||||
continue
|
||||
credit_type = credit.get("type", "").lower()
|
||||
contributors = credit.get("contributors", [])
|
||||
role = role_mapping.get(credit_type, credit_type)
|
||||
if isinstance(contributors, list):
|
||||
for contributor in contributors:
|
||||
if isinstance(contributor, dict):
|
||||
name = contributor.get("name")
|
||||
if name:
|
||||
result.setdefault(role, []).append(name)
|
||||
return result
|
||||
|
||||
|
||||
def _extract_track_contributors(track_json: dict) -> dict[str, list[str]]:
|
||||
"""Extract contributors from track JSON."""
|
||||
# Try credits first (API v2)
|
||||
track_credits = track_json.get("credits")
|
||||
if track_credits and isinstance(track_credits, list):
|
||||
contributors = _process_credits_contributors(track_credits)
|
||||
if contributors:
|
||||
return contributors
|
||||
# Fallback to old format
|
||||
raw_contributors = track_json.get("contributors")
|
||||
if raw_contributors:
|
||||
return _normalize_contributors(raw_contributors)
|
||||
return {}
|
||||
|
||||
|
||||
def _process_genre_item(g: object) -> str | None:
|
||||
"""Extract genre name from various formats."""
|
||||
if isinstance(g, str) and g:
|
||||
return g
|
||||
if isinstance(g, dict):
|
||||
name = g.get("name")
|
||||
if isinstance(name, str) and name:
|
||||
return name
|
||||
return None
|
||||
|
||||
|
||||
def _deduplicate_genres(genres: list[str]) -> list[str]:
|
||||
"""Deduplicate genres while preserving order."""
|
||||
seen: set[str] = set()
|
||||
unique: list[str] = []
|
||||
for g in genres:
|
||||
if g not in seen:
|
||||
seen.add(g)
|
||||
unique.append(g)
|
||||
return unique
|
||||
|
||||
|
||||
def _extract_album_label_genres(album_json: dict) -> tuple[str, list[str]]:
|
||||
"""Extract label and genres from album JSON."""
|
||||
# Label
|
||||
label = album_json.get("label") or album_json.get("recordLabel")
|
||||
label_str = label if isinstance(label, str) else ""
|
||||
|
||||
# Genres
|
||||
raw_genres = album_json.get("genres") or album_json.get("genre")
|
||||
genres: list[str] = []
|
||||
|
||||
if isinstance(raw_genres, list):
|
||||
for g in raw_genres:
|
||||
genre = _process_genre_item(g)
|
||||
if genre:
|
||||
genres.append(genre)
|
||||
elif isinstance(raw_genres, str) and raw_genres:
|
||||
genres.append(raw_genres)
|
||||
else:
|
||||
genre = _process_genre_item(raw_genres)
|
||||
if genre:
|
||||
genres.append(genre)
|
||||
|
||||
# Deduplicate while preserving order
|
||||
if genres:
|
||||
return label_str, _deduplicate_genres(genres)
|
||||
return label_str, []
|
||||
|
||||
|
||||
def _extract_album_contributors(album_json: dict) -> dict[str, list[str]]:
|
||||
"""Extract contributors from album JSON."""
|
||||
# Try credits first (API v2)
|
||||
album_credits = album_json.get("credits")
|
||||
if album_credits and isinstance(album_credits, list):
|
||||
contributors = _process_credits_contributors(album_credits)
|
||||
if contributors:
|
||||
return contributors
|
||||
# Fallback to old format
|
||||
raw_contributors = album_json.get("contributors")
|
||||
if raw_contributors:
|
||||
return _normalize_contributors(raw_contributors)
|
||||
return {}
|
||||
|
||||
|
||||
def parse_track_and_album_extras(
|
||||
track_json: dict | None,
|
||||
album_json: dict | None,
|
||||
) -> dict:
|
||||
"""Extract extra metadata from raw TIDAL JSON for a track and its album.
|
||||
|
||||
Returned dict keys (all optional, may be missing or empty):
|
||||
- bpm: int | None
|
||||
- label: str
|
||||
- genres: list[str]
|
||||
- contributors_by_role: dict[str, list[str]]
|
||||
"""
|
||||
|
||||
extras: dict = {
|
||||
"bpm": None,
|
||||
"label": "",
|
||||
"genres": [],
|
||||
"contributors_by_role": {},
|
||||
}
|
||||
|
||||
# Extract from track
|
||||
if isinstance(track_json, dict):
|
||||
extras["bpm"] = _extract_bpm_from_track(track_json)
|
||||
extras["contributors_by_role"] = _extract_track_contributors(track_json)
|
||||
|
||||
# Extract from album
|
||||
if isinstance(album_json, dict):
|
||||
label, genres = _extract_album_label_genres(album_json)
|
||||
extras["label"] = label
|
||||
extras["genres"] = genres
|
||||
|
||||
# If we did not get track-level contributors, try album-level
|
||||
if not extras["contributors_by_role"]:
|
||||
extras["contributors_by_role"] = _extract_album_contributors(album_json)
|
||||
|
||||
return extras
|
||||
|
||||
|
||||
def extract_contributor_names(
|
||||
contributors_by_role: dict[str, list[str]] | None,
|
||||
role: str,
|
||||
delimiter: str = ", ",
|
||||
) -> str:
|
||||
"""Return a delimited string of contributor names for a given role.
|
||||
|
||||
If the role is not present or has no names, returns an empty string.
|
||||
Role matching is case-insensitive.
|
||||
"""
|
||||
if not contributors_by_role:
|
||||
return ""
|
||||
|
||||
# Normalise keys to lowercase for robust lookups.
|
||||
role_lc = role.lower()
|
||||
for r, names in contributors_by_role.items():
|
||||
if not isinstance(r, str):
|
||||
continue
|
||||
if r.lower() == role_lc and isinstance(names, list):
|
||||
filtered = [n for n in names if isinstance(n, str) and n]
|
||||
if filtered:
|
||||
return delimiter.join(filtered)
|
||||
|
||||
return ""
|
||||
|
||||
+65
-4
@@ -32,6 +32,13 @@ class Metadata:
|
||||
upc: str
|
||||
target_upc: dict[str, str]
|
||||
explicit: bool
|
||||
# New enriched metadata fields
|
||||
genre: str
|
||||
label: str
|
||||
bpm: int | None
|
||||
producers: str
|
||||
composers_detailed: str
|
||||
lyricists: str
|
||||
m: mutagen.mp4.MP4 | mutagen.mp4.MP4 | mutagen.flac.FLAC
|
||||
|
||||
def __init__(
|
||||
@@ -61,6 +68,13 @@ class Metadata:
|
||||
replay_gain_write: bool = True,
|
||||
upc: str = "",
|
||||
explicit: bool = False,
|
||||
# New enriched metadata kwargs (all optional)
|
||||
genre: str = "",
|
||||
label: str = "",
|
||||
bpm: int | None = None,
|
||||
producers: str = "",
|
||||
composers_detailed: str = "",
|
||||
lyricists: str = "",
|
||||
):
|
||||
self.path_file = path_file
|
||||
self.title = title
|
||||
@@ -87,6 +101,13 @@ class Metadata:
|
||||
self.upc = upc
|
||||
self.target_upc = target_upc
|
||||
self.explicit = explicit
|
||||
# Store enriched metadata
|
||||
self.genre = genre
|
||||
self.label = label
|
||||
self.bpm = bpm
|
||||
self.producers = producers
|
||||
self.composers_detailed = composers_detailed
|
||||
self.lyricists = lyricists
|
||||
self.m: mutagen.FileType = mutagen.File(self.path_file)
|
||||
|
||||
def _cover(self) -> bool:
|
||||
@@ -139,12 +160,24 @@ class Metadata:
|
||||
self.m.tags["DISCNUMBER"] = str(self.discnumber)
|
||||
self.m.tags["DISCTOTAL"] = str(self.totaldisc)
|
||||
self.m.tags["DATE"] = self.date
|
||||
self.m.tags["COMPOSER"] = self.composer
|
||||
self.m.tags["COMPOSER"] = self.composer if self.composer else self.composers_detailed
|
||||
self.m.tags["ISRC"] = self.isrc
|
||||
self.m.tags["LYRICS"] = self.lyrics
|
||||
self.m.tags["UNSYNCEDLYRICS"] = self.lyrics_unsynced
|
||||
self.m.tags["URL"] = self.url_share
|
||||
self.m.tags[self.target_upc["FLAC"]] = self.upc
|
||||
# Enriched fields
|
||||
if self.genre:
|
||||
self.m.tags["GENRE"] = self.genre
|
||||
if self.label:
|
||||
# Using LABEL; some tools map PUBLISHER, but LABEL is widely recognised in Vorbis.
|
||||
self.m.tags["LABEL"] = self.label
|
||||
if self.bpm is not None:
|
||||
self.m.tags["BPM"] = str(self.bpm)
|
||||
if self.producers:
|
||||
self.m.tags["PRODUCER"] = self.producers
|
||||
if self.lyricists:
|
||||
self.m.tags["LYRICIST"] = self.lyricists
|
||||
|
||||
if self.replay_gain_write:
|
||||
self.m.tags["REPLAYGAIN_ALBUM_GAIN"] = str(self.album_replay_gain)
|
||||
@@ -163,12 +196,29 @@ class Metadata:
|
||||
self.m.tags.add(TRCK(encoding=3, text=str(self.tracknumber)))
|
||||
self.m.tags.add(TRCK(encoding=3, text=self.discnumber))
|
||||
self.m.tags.add(TDRC(encoding=3, text=self.date))
|
||||
self.m.tags.add(TCOM(encoding=3, text=self.composer))
|
||||
self.m.tags.add(TCOM(encoding=3, text=self.composer if self.composer else self.composers_detailed))
|
||||
self.m.tags.add(TSRC(encoding=3, text=self.isrc))
|
||||
self.m.tags.add(SYLT(encoding=3, desc="text", text=self.lyrics))
|
||||
self.m.tags.add(USLT(encoding=3, desc="text", text=self.lyrics_unsynced))
|
||||
self.m.tags.add(WOAS(encoding=3, text=self.isrc))
|
||||
self.m.tags.add(TXXX(encoding=3, desc=self.target_upc["MP3"], text=self.upc))
|
||||
# Enriched fields
|
||||
if self.genre:
|
||||
from mutagen.id3 import TCON
|
||||
|
||||
self.m.tags.add(TCON(encoding=3, text=self.genre))
|
||||
if self.label:
|
||||
from mutagen.id3 import TPUB
|
||||
|
||||
self.m.tags.add(TPUB(encoding=3, text=self.label))
|
||||
if self.bpm is not None:
|
||||
from mutagen.id3 import TBPM
|
||||
|
||||
self.m.tags.add(TBPM(encoding=3, text=str(self.bpm)))
|
||||
if self.producers:
|
||||
self.m.tags.add(TXXX(encoding=3, desc="PRODUCER", text=self.producers))
|
||||
if self.lyricists:
|
||||
self.m.tags.add(TXXX(encoding=3, desc="LYRICIST", text=self.lyricists))
|
||||
|
||||
if self.replay_gain_write:
|
||||
self.m.tags.add(TXXX(encoding=3, desc="REPLAYGAIN_ALBUM_GAIN", text=str(self.album_replay_gain)))
|
||||
@@ -184,15 +234,26 @@ class Metadata:
|
||||
self.m.tags["cprt"] = self.copy_right
|
||||
self.m.tags["trkn"] = [[self.tracknumber, self.totaltrack]]
|
||||
self.m.tags["disk"] = [[self.discnumber, self.totaldisc]]
|
||||
# self.m.tags['\xa9gen'] = self.genre
|
||||
if self.genre:
|
||||
self.m.tags["\xa9gen"] = self.genre
|
||||
self.m.tags["\xa9day"] = self.date
|
||||
self.m.tags["\xa9wrt"] = self.composer
|
||||
self.m.tags["\xa9wrt"] = self.composer if self.composer else self.composers_detailed
|
||||
self.m.tags["\xa9lyr"] = self.lyrics
|
||||
self.m.tags["----:com.apple.iTunes:UNSYNCEDLYRICS"] = self.lyrics_unsynced.encode("utf-8")
|
||||
self.m.tags["isrc"] = self.isrc
|
||||
self.m.tags["\xa9url"] = self.url_share
|
||||
self.m.tags[f"----:com.apple.iTunes:{self.target_upc['MP4']}"] = self.upc.encode("utf-8")
|
||||
self.m.tags["rtng"] = [1 if self.explicit else 0]
|
||||
# Custom iTunes free-form tags for label / credits
|
||||
if self.label:
|
||||
self.m.tags["----:com.apple.iTunes:LABEL"] = self.label.encode("utf-8")
|
||||
if self.producers:
|
||||
self.m.tags["----:com.apple.iTunes:PRODUCER"] = self.producers.encode("utf-8")
|
||||
if self.lyricists:
|
||||
self.m.tags["----:com.apple.iTunes:LYRICIST"] = self.lyricists.encode("utf-8")
|
||||
if self.bpm is not None:
|
||||
# Standard MP4 tempo atom
|
||||
self.m.tags["tmpo"] = [int(self.bpm)]
|
||||
|
||||
if self.replay_gain_write:
|
||||
self.m.tags["----:com.apple.iTunes:REPLAYGAIN_ALBUM_GAIN"] = str(self.album_replay_gain).encode("utf-8")
|
||||
|
||||
@@ -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
|
||||
+205
-12
@@ -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))
|
||||
|
||||
+273
-23
@@ -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="minimumSize">
|
||||
<size>
|
||||
<width>300</width>
|
||||
<height>300</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>280</width>
|
||||
<height>280</height>
|
||||
<width>600</width>
|
||||
<height>16777215</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="baseSize">
|
||||
<size>
|
||||
<width>0</width>
|
||||
<height>0</height>
|
||||
</size>
|
||||
<property name="currentIndex">
|
||||
<number>1</number>
|
||||
</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">
|
||||
<property name="usesScrollButtons">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignmentFlag::AlignHCenter|Qt::AlignmentFlag::AlignTop</set>
|
||||
<property name="documentMode">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<property name="tabsClosable">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<property name="movable">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<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">
|
||||
|
||||
@@ -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 "—")
|
||||
Reference in New Issue
Block a user