diff options
Diffstat (limited to 'tidal_dl_ng/helper/metadata_utils.py')
| -rw-r--r-- | tidal_dl_ng/helper/metadata_utils.py | 227 |
1 files changed, 227 insertions, 0 deletions
diff --git a/tidal_dl_ng/helper/metadata_utils.py b/tidal_dl_ng/helper/metadata_utils.py new file mode 100644 index 0000000..eac69bd --- /dev/null +++ b/tidal_dl_ng/helper/metadata_utils.py @@ -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 [] |
