This commit is contained in:
2025-12-31 05:17:37 -08:00
parent 3bbaa28fb4
commit e8842ceded
10 changed files with 1255 additions and 29 deletions

434
Provider/HIFI.py Normal file
View File

@@ -0,0 +1,434 @@
from __future__ import annotations
import re
import sys
from typing import Any, Dict, List, Optional, Tuple
import httpx
from ProviderCore.base import Provider, SearchResult
from SYS.logger import log
DEFAULT_API_URLS = (
"https://tidal-api.binimum.org",
)
_KEY_TO_PARAM: Dict[str, str] = {
"album": "al",
"artist": "a",
"playlist": "p",
"video": "v",
"song": "s",
"track": "s",
"title": "s",
}
_DELIMITERS_RE = re.compile(r"[;,]")
_SEGMENT_BOUNDARY_RE = re.compile(r"(?=\b\w+\s*:)")
class HIFI(Provider):
"""Provider that targets the HiFi-RestAPI (Tidal proxy) search endpoint.
The CLI can supply a list of fail-over URLs via ``provider.hifi.api_urls`` or
``provider.hifi.api_url`` in the config. When not configured, it defaults to
https://tidal-api.binimum.org.
"""
def __init__(self, config: Optional[Dict[str, Any]] = None) -> None:
super().__init__(config)
self.api_urls = self._resolve_api_urls()
def validate(self) -> bool:
return bool(self.api_urls)
def search(
self,
query: str,
limit: int = 50,
filters: Optional[Dict[str, Any]] = None,
**_kwargs: Any,
) -> List[SearchResult]:
if limit <= 0:
return []
params = self._build_search_params(query)
if not params:
return []
payload: Optional[Dict[str, Any]] = None
for base in self.api_urls:
endpoint = f"{base.rstrip('/')}/search/"
try:
resp = httpx.get(endpoint, params=params, timeout=10.0)
resp.raise_for_status()
payload = resp.json()
break
except Exception as exc:
log(f"[hifi] Search failed for {endpoint}: {exc}", file=sys.stderr)
continue
if not payload:
return []
data = payload.get("data") or {}
items = data.get("items") or []
results: List[SearchResult] = []
for item in items:
if limit and len(results) >= limit:
break
result = self._item_to_result(item)
if result is not None:
results.append(result)
return results[:limit]
def _resolve_api_urls(self) -> List[str]:
urls: List[str] = []
raw = self.config.get("api_urls")
if raw is None:
raw = self.config.get("api_url")
if isinstance(raw, (list, tuple)):
urls.extend(str(item).strip() for item in raw if isinstance(item, str))
elif isinstance(raw, str):
urls.append(raw.strip())
cleaned = [u.rstrip("/") for u in urls if isinstance(u, str) and u.strip()]
if not cleaned:
cleaned = [DEFAULT_API_URLS[0]]
return cleaned
def _build_search_params(self, query: str) -> Dict[str, str]:
cleaned = str(query or "").strip()
if not cleaned:
return {}
segments: List[str] = []
for chunk in _DELIMITERS_RE.split(cleaned):
chunk = chunk.strip()
if not chunk:
continue
if ":" in chunk:
for sub in _SEGMENT_BOUNDARY_RE.split(chunk):
part = sub.strip()
if part:
segments.append(part)
else:
segments.append(chunk)
key_values: Dict[str, str] = {}
free_text: List[str] = []
for segment in segments:
if ":" not in segment:
free_text.append(segment)
continue
key, value = segment.split(":", 1)
key = key.strip().lower()
value = value.strip().strip('"').strip("'")
if value:
key_values[key] = value
params: Dict[str, str] = {}
for key, value in key_values.items():
if not value:
continue
mapped = _KEY_TO_PARAM.get(key)
if mapped:
params[mapped] = value
general = " ".join(part for part in free_text if part).strip()
if general:
params.setdefault("s", general)
elif not params:
params["s"] = cleaned
return params
@staticmethod
def _format_duration(seconds: Any) -> str:
try:
total = int(seconds)
if total < 0:
return ""
except Exception:
return ""
minutes, secs = divmod(total, 60)
return f"{minutes}:{secs:02d}"
@staticmethod
def _stringify(value: Any) -> str:
text = str(value or "").strip()
return text
@staticmethod
def _extract_artists(item: Dict[str, Any]) -> List[str]:
names: List[str] = []
artists = item.get("artists")
if isinstance(artists, list):
for artist in artists:
if isinstance(artist, dict):
name = str(artist.get("name") or "").strip()
if name and name not in names:
names.append(name)
if not names:
primary = item.get("artist")
if isinstance(primary, dict):
name = str(primary.get("name") or "").strip()
if name:
names.append(name)
return names
def _item_to_result(self, item: Dict[str, Any]) -> Optional[SearchResult]:
if not isinstance(item, dict):
return None
title = str(item.get("title") or "").strip()
if not title:
return None
identifier = item.get("id")
if identifier is None:
return None
try:
track_id = int(identifier)
except (TypeError, ValueError):
return None
# Avoid tidal.com URLs entirely; selection will resolve to a decoded MPD.
path = f"hifi://track/{track_id}"
artists = self._extract_artists(item)
artist_display = ", ".join(artists)
album = item.get("album")
album_title = ""
if isinstance(album, dict):
album_title = str(album.get("title") or "").strip()
detail_parts: List[str] = []
if artist_display:
detail_parts.append(artist_display)
if album_title:
detail_parts.append(album_title)
detail = " | ".join(detail_parts)
columns: List[tuple[str, str]] = []
if artist_display:
columns.append(("Artist", artist_display))
if album_title:
columns.append(("Album", album_title))
duration_text = self._format_duration(item.get("duration"))
if duration_text:
columns.append(("Duration", duration_text))
audio_quality = str(item.get("audioQuality") or "").strip()
if audio_quality:
columns.append(("Quality", audio_quality))
tags = {"tidal"}
if audio_quality:
tags.add(f"quality:{audio_quality.lower()}")
metadata = item.get("mediaMetadata")
if isinstance(metadata, dict):
tag_values = metadata.get("tags") or []
for tag in tag_values:
if isinstance(tag, str) and tag.strip():
tags.add(tag.strip().lower())
return SearchResult(
table="hifi",
title=title,
path=path,
detail=detail,
annotations=["tidal"],
media_kind="audio",
tag=tags,
columns=columns,
full_metadata=item,
)
def _extract_track_selection_context(
self, selected_items: List[Any]
) -> List[Tuple[int, str, str]]:
contexts: List[Tuple[int, str, str]] = []
seen_ids: set[int] = set()
for item in selected_items or []:
payload: Dict[str, Any] = {}
if isinstance(item, dict):
payload = item
else:
try:
payload = (
item.to_dict()
if hasattr(item, "to_dict")
and callable(getattr(item, "to_dict"))
else {}
)
except Exception:
payload = {}
if not payload:
try:
payload = {
"title": getattr(item, "title", None),
"path": getattr(item, "path", None),
"url": getattr(item, "url", None),
"full_metadata": getattr(item, "full_metadata", None),
}
except Exception:
payload = {}
meta = (
payload.get("full_metadata")
if isinstance(payload.get("full_metadata"), dict)
else payload
)
if not isinstance(meta, dict):
meta = {}
raw_id = meta.get("trackId") or meta.get("id") or payload.get("id")
if raw_id is None:
continue
try:
track_id = int(raw_id)
except (TypeError, ValueError):
continue
if track_id in seen_ids:
continue
seen_ids.add(track_id)
title = (
payload.get("title")
or meta.get("title")
or payload.get("name")
or payload.get("path")
or payload.get("url")
)
if not title:
title = f"Track {track_id}"
path = (
payload.get("path")
or payload.get("url")
or f"hifi://track/{track_id}"
)
contexts.append((track_id, str(title).strip(), str(path).strip()))
return contexts
def _fetch_track_details(self, track_id: int) -> Optional[Dict[str, Any]]:
if track_id <= 0:
return None
params = {"id": str(track_id)}
for base in self.api_urls:
endpoint = f"{base.rstrip('/')}/track/"
try:
resp = httpx.get(endpoint, params=params, timeout=10.0)
resp.raise_for_status()
payload = resp.json()
data = payload.get("data")
if isinstance(data, dict):
return data
except Exception as exc:
log(f"[hifi] Track lookup failed for {endpoint}: {exc}", file=sys.stderr)
continue
return None
def _build_track_columns(self, detail: Dict[str, Any], track_id: int) -> List[Tuple[str, str]]:
values: List[Tuple[str, str]] = [
("Track ID", str(track_id)),
("Quality", self._stringify(detail.get("audioQuality"))),
("Mode", self._stringify(detail.get("audioMode"))),
("Asset", self._stringify(detail.get("assetPresentation"))),
("Manifest Type", self._stringify(detail.get("manifestMimeType"))),
("Manifest Hash", self._stringify(detail.get("manifestHash"))),
("Bit Depth", self._stringify(detail.get("bitDepth"))),
("Sample Rate", self._stringify(detail.get("sampleRate"))),
]
return [(name, value) for name, value in values if value]
def selector(
self,
selected_items: List[Any],
*,
ctx: Any,
stage_is_last: bool = True,
**_kwargs: Any,
) -> bool:
if not stage_is_last:
return False
contexts = self._extract_track_selection_context(selected_items)
if not contexts:
return False
track_details: List[Tuple[int, str, str, Dict[str, Any]]] = []
for track_id, title, path in contexts:
detail = self._fetch_track_details(track_id)
if detail:
track_details.append((track_id, title, path, detail))
if not track_details:
return False
try:
from SYS.rich_display import stdout_console
from SYS.result_table import ResultTable
except Exception:
return False
table = ResultTable("HIFI Track").set_preserve_order(True)
table.set_table("hifi.track")
results_payload: List[Dict[str, Any]] = []
for track_id, title, path, detail in track_details:
# Decode the DASH MPD manifest to a local file and use it as the selectable/playable path.
try:
from cmdlet._shared import resolve_tidal_manifest_path
manifest_path = resolve_tidal_manifest_path(
{"full_metadata": detail, "path": f"hifi://track/{track_id}"}
)
except Exception:
manifest_path = None
resolved_path = str(manifest_path) if manifest_path else f"hifi://track/{track_id}"
artists = self._extract_artists(detail)
artist_display = ", ".join(artists) if artists else ""
columns = self._build_track_columns(detail, track_id)
if artist_display:
columns.insert(1, ("Artist", artist_display))
album = detail.get("album")
if isinstance(album, dict):
album_title = self._stringify(album.get("title"))
else:
album_title = self._stringify(detail.get("album"))
if album_title:
insert_pos = 2 if artist_display else 1
columns.insert(insert_pos, ("Album", album_title))
result = SearchResult(
table="hifi.track",
title=title,
path=resolved_path,
detail=f"id:{track_id}",
annotations=["tidal", "track"],
media_kind="audio",
columns=columns,
full_metadata=detail,
)
table.add_result(result)
try:
results_payload.append(result.to_dict())
except Exception:
results_payload.append({
"table": "hifi.track",
"title": result.title,
"path": result.path,
})
try:
ctx.set_last_result_table(table, results_payload)
ctx.set_current_stage_table(table)
except Exception:
pass
try:
stdout_console().print()
stdout_console().print(table)
except Exception:
pass
return True

View File

@@ -8,8 +8,13 @@ import requests
import sys
import json
import subprocess
try: # Optional dependency for IMDb scraping
from imdbinfo.services import search_title # type: ignore
except ImportError: # pragma: no cover - optional
search_title = None # type: ignore[assignment]
from SYS.logger import log, debug
from SYS.metadata import imdb_tag
try: # Optional dependency
import musicbrainzngs # type: ignore
@@ -607,6 +612,139 @@ class MusicBrainzMetadataProvider(MetadataProvider):
return tags
class ImdbMetadataProvider(MetadataProvider):
"""Metadata provider for IMDb titles (movies/series/episodes)."""
@property
def name(self) -> str: # type: ignore[override]
return "imdb"
@staticmethod
def _extract_imdb_id(text: str) -> str:
raw = str(text or "").strip()
if not raw:
return ""
# Exact tt123 pattern
m = re.search(r"(tt\d+)", raw, re.IGNORECASE)
if m:
imdb_id = m.group(1).lower()
return imdb_id if imdb_id.startswith("tt") else f"tt{imdb_id}"
# Bare numeric IDs (e.g., "0118883")
if raw.isdigit() and len(raw) >= 6:
return f"tt{raw}"
# Last-resort: extract first digit run
m_digits = re.search(r"(\d{6,})", raw)
if m_digits:
return f"tt{m_digits.group(1)}"
return ""
def search(self, query: str, limit: int = 10) -> List[Dict[str, Any]]:
q = (query or "").strip()
if not q:
return []
imdb_id = self._extract_imdb_id(q)
if imdb_id:
try:
data = imdb_tag(imdb_id)
raw_tags = data.get("tag") if isinstance(data, dict) else []
title = None
year = None
if isinstance(raw_tags, list):
for tag in raw_tags:
if not isinstance(tag, str):
continue
if tag.startswith("title:"):
title = tag.split(":", 1)[1]
elif tag.startswith("year:"):
year = tag.split(":", 1)[1]
return [
{
"title": title or imdb_id,
"artist": "",
"album": "",
"year": str(year or ""),
"provider": self.name,
"imdb_id": imdb_id,
"raw": data,
}
]
except Exception as exc:
log(f"IMDb lookup failed: {exc}", file=sys.stderr)
return []
if search_title is None:
log("imdbinfo is not installed; skipping IMDb scrape", file=sys.stderr)
return []
try:
search_result = search_title(q)
titles = getattr(search_result, "titles", None) or []
except Exception as exc:
log(f"IMDb search failed: {exc}", file=sys.stderr)
return []
items: List[Dict[str, Any]] = []
for entry in titles[:limit]:
imdb_id = self._extract_imdb_id(
getattr(entry, "imdb_id", None)
or getattr(entry, "imdbId", None)
or getattr(entry, "id", None)
)
title = getattr(entry, "title", "") or getattr(entry, "title_localized", "")
year = str(getattr(entry, "year", "") or "")[:4]
kind = getattr(entry, "kind", "") or ""
rating = getattr(entry, "rating", None)
items.append(
{
"title": title,
"artist": "",
"album": kind,
"year": year,
"provider": self.name,
"imdb_id": imdb_id,
"kind": kind,
"rating": rating,
"raw": entry,
}
)
return items
def to_tags(self, item: Dict[str, Any]) -> List[str]:
imdb_id = self._extract_imdb_id(
item.get("imdb_id") or item.get("id") or item.get("imdb") or ""
)
try:
if imdb_id:
data = imdb_tag(imdb_id)
raw_tags = data.get("tag") if isinstance(data, dict) else []
tags = [t for t in raw_tags if isinstance(t, str)]
if tags:
return tags
except Exception as exc:
log(f"IMDb tag extraction failed: {exc}", file=sys.stderr)
tags = super().to_tags(item)
if imdb_id:
tags.append(f"imdb:{imdb_id}")
seen: set[str] = set()
deduped: List[str] = []
for t in tags:
s = str(t or "").strip()
if not s:
continue
k = s.lower()
if k in seen:
continue
seen.add(k)
deduped.append(s)
return deduped
class YtdlpMetadataProvider(MetadataProvider):
"""Metadata provider that extracts tags from a supported URL using yt-dlp.
@@ -764,6 +902,7 @@ _METADATA_PROVIDERS: Dict[str,
"google": GoogleBooksMetadataProvider,
"isbnsearch": ISBNsearchMetadataProvider,
"musicbrainz": MusicBrainzMetadataProvider,
"imdb": ImdbMetadataProvider,
"ytdlp": YtdlpMetadataProvider,
}