Files
tidal-dl/tidal_dl_ng/helper/metadata_utils.py
T

228 lines
7.2 KiB
Python
Raw Normal View History

"""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 []