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:
Warry
2025-11-29 12:10:06 +01:00
parent 28a6fe5a7d
commit d2c3e8ab8f
26 changed files with 4890 additions and 1626 deletions
+220
View File
@@ -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,
)