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,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 []
|
||||
Reference in New Issue
Block a user