This commit is contained in:
2025-12-31 06:00:07 -08:00
parent e8842ceded
commit 9464bd0d21
3 changed files with 138 additions and 15 deletions

68
API/hifi.py Normal file
View File

@@ -0,0 +1,68 @@
from __future__ import annotations
from typing import Any, Dict, Optional
from .HTTP import HTTPClient
DEFAULT_BASE_URL = "https://tidal-api.binimum.org"
class HifiApiError(Exception):
"""Raised when the HiFi API returns an error or malformed response."""
class HifiApiClient:
"""Lightweight client for the hifi-api endpoints.
Supported endpoints:
- GET /search/ with exactly one of s, a, v, p
- GET /track/ with id (and optional quality)
- GET /info/ with id
"""
def __init__(self, base_url: str = DEFAULT_BASE_URL, *, timeout: float = 10.0) -> None:
self.base_url = str(base_url or DEFAULT_BASE_URL).rstrip("/")
self.timeout = float(timeout)
def _get_json(self, path: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
url = f"{self.base_url}/{str(path or '').lstrip('/')}"
with HTTPClient(timeout=self.timeout) as client:
response = client.get(url, params=params, allow_redirects=True)
response.raise_for_status()
try:
return response.json()
except Exception as exc: # pragma: no cover - defensive
raise HifiApiError(f"Invalid JSON response from {url}: {exc}") from exc
def search(self, params: Dict[str, str]) -> Dict[str, Any]:
usable = {k: v for k, v in (params or {}).items() if v}
search_keys = [key for key in ("s", "a", "v", "p") if usable.get(key)]
if not search_keys:
raise HifiApiError("One of s/a/v/p is required for /search/")
if len(search_keys) > 1:
first = search_keys[0]
usable = {first: usable[first]}
return self._get_json("search/", params=usable)
def track(self, track_id: int, *, quality: Optional[str] = None) -> Dict[str, Any]:
try:
track_int = int(track_id)
except Exception as exc:
raise HifiApiError(f"track_id must be int-compatible: {exc}") from exc
if track_int <= 0:
raise HifiApiError("track_id must be positive")
params: Dict[str, Any] = {"id": track_int}
if quality:
params["quality"] = str(quality)
return self._get_json("track/", params=params)
def info(self, track_id: int) -> Dict[str, Any]:
try:
track_int = int(track_id)
except Exception as exc:
raise HifiApiError(f"track_id must be int-compatible: {exc}") from exc
if track_int <= 0:
raise HifiApiError("track_id must be positive")
return self._get_json("info/", params={"id": track_int})

View File

@@ -1,2 +1,2 @@
# Medeia MPV script options # Medeia MPV script options
store=video store=tutorial

View File

@@ -4,8 +4,7 @@ import re
import sys import sys
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
import httpx from API.hifi import HifiApiClient
from ProviderCore.base import Provider, SearchResult from ProviderCore.base import Provider, SearchResult
from SYS.logger import log from SYS.logger import log
@@ -38,6 +37,11 @@ class HIFI(Provider):
def __init__(self, config: Optional[Dict[str, Any]] = None) -> None: def __init__(self, config: Optional[Dict[str, Any]] = None) -> None:
super().__init__(config) super().__init__(config)
self.api_urls = self._resolve_api_urls() self.api_urls = self._resolve_api_urls()
try:
self.api_timeout = float(self.config.get("timeout", 10.0))
except Exception:
self.api_timeout = 10.0
self.api_clients = [HifiApiClient(base_url=url, timeout=self.api_timeout) for url in self.api_urls]
def validate(self) -> bool: def validate(self) -> bool:
return bool(self.api_urls) return bool(self.api_urls)
@@ -59,9 +63,9 @@ class HIFI(Provider):
for base in self.api_urls: for base in self.api_urls:
endpoint = f"{base.rstrip('/')}/search/" endpoint = f"{base.rstrip('/')}/search/"
try: try:
resp = httpx.get(endpoint, params=params, timeout=10.0) client = self._get_api_client_for_base(base)
resp.raise_for_status() payload = client.search(params) if client else None
payload = resp.json() if payload is not None:
break break
except Exception as exc: except Exception as exc:
log(f"[hifi] Search failed for {endpoint}: {exc}", file=sys.stderr) log(f"[hifi] Search failed for {endpoint}: {exc}", file=sys.stderr)
@@ -71,7 +75,7 @@ class HIFI(Provider):
return [] return []
data = payload.get("data") or {} data = payload.get("data") or {}
items = data.get("items") or [] items = self._extract_track_items(data)
results: List[SearchResult] = [] results: List[SearchResult] = []
for item in items: for item in items:
if limit and len(results) >= limit: if limit and len(results) >= limit:
@@ -82,6 +86,57 @@ class HIFI(Provider):
return results[:limit] return results[:limit]
def _get_api_client_for_base(self, base_url: str) -> Optional[HifiApiClient]:
base = base_url.rstrip("/")
for client in self.api_clients:
if getattr(client, "base_url", "").rstrip("/") == base:
return client
return None
def _extract_track_items(self, data: Any) -> List[Dict[str, Any]]:
if isinstance(data, list):
return [item for item in data if isinstance(item, dict)]
if not isinstance(data, dict):
return []
items: List[Dict[str, Any]] = []
direct = data.get("items")
if isinstance(direct, list):
items.extend(item for item in direct if isinstance(item, dict))
tracks_section = data.get("tracks")
if isinstance(tracks_section, dict):
track_items = tracks_section.get("items")
if isinstance(track_items, list):
items.extend(item for item in track_items if isinstance(item, dict))
top_hits = data.get("topHits")
if isinstance(top_hits, list):
for hit in top_hits:
if not isinstance(hit, dict):
continue
hit_type = str(hit.get("type") or "").upper()
if hit_type != "TRACKS":
continue
value = hit.get("value")
if isinstance(value, dict):
items.append(value)
seen: set[int] = set()
deduped: List[Dict[str, Any]] = []
for item in items:
track_id = item.get("id") or item.get("trackId")
try:
track_int = int(track_id)
except Exception:
track_int = None
if track_int is None or track_int in seen:
continue
seen.add(track_int)
deduped.append(item)
return deduped
def _resolve_api_urls(self) -> List[str]: def _resolve_api_urls(self) -> List[str]:
urls: List[str] = [] urls: List[str] = []
raw = self.config.get("api_urls") raw = self.config.get("api_urls")
@@ -210,10 +265,12 @@ class HIFI(Provider):
detail = " | ".join(detail_parts) detail = " | ".join(detail_parts)
columns: List[tuple[str, str]] = [] columns: List[tuple[str, str]] = []
if artist_display: if title:
columns.append(("Artist", artist_display)) columns.append(("Title", title))
if album_title: if album_title:
columns.append(("Album", album_title)) columns.append(("Album", album_title))
if artist_display:
columns.append(("Artist", artist_display))
duration_text = self._format_duration(item.get("duration")) duration_text = self._format_duration(item.get("duration"))
if duration_text: if duration_text:
columns.append(("Duration", duration_text)) columns.append(("Duration", duration_text))
@@ -311,14 +368,12 @@ class HIFI(Provider):
def _fetch_track_details(self, track_id: int) -> Optional[Dict[str, Any]]: def _fetch_track_details(self, track_id: int) -> Optional[Dict[str, Any]]:
if track_id <= 0: if track_id <= 0:
return None return None
params = {"id": str(track_id)}
for base in self.api_urls: for base in self.api_urls:
endpoint = f"{base.rstrip('/')}/track/" endpoint = f"{base.rstrip('/')}/track/"
try: try:
resp = httpx.get(endpoint, params=params, timeout=10.0) client = self._get_api_client_for_base(base)
resp.raise_for_status() payload = client.track(track_id) if client else None
payload = resp.json() data = payload.get("data") if isinstance(payload, dict) else None
data = payload.get("data")
if isinstance(data, dict): if isinstance(data, dict):
return data return data
except Exception as exc: except Exception as exc: