- Improved playlist item fetching by adding robust pagination and low-level request hooks for better testing and flexibility. - Refactored track ID extraction logic into reusable helpers for clarity. - Improved initialization for dialog components to avoid circular dependencies in the GUI. - Added new unit tests to validate playlist operations, including track addition/removal and API integration. - Updated metadata functions to simplify dictionary access using `.get` for better readability. - Ensured PySide6 system dependencies are installed in GitHub Actions to address CI compatibility issues. - Streamlined UI package initialization to improve modularity and robustness of imports.
228 lines
7.2 KiB
Python
228 lines
7.2 KiB
Python
"""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 value.get("name"):
|
|
return str(value["name"])
|
|
for k in ("label", "title", "genre", "name"):
|
|
if value.get(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 item.get(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 []
|