This commit is contained in:
2026-01-11 14:46:41 -08:00
parent 1f3de7db1c
commit 275f18cb31
19 changed files with 2741 additions and 394 deletions

View File

@@ -12,19 +12,18 @@ from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional, Tuple
from urllib.parse import urlparse
from API.hifi import HifiApiClient
from ProviderCore.base import Provider, SearchResult, parse_inline_query_arguments
from Provider.tidal_shared import (
from API.Tidal import (
HifiApiClient,
build_track_tags,
coerce_duration_seconds,
extract_artists,
stringify,
)
from ProviderCore.base import Provider, SearchResult, parse_inline_query_arguments
from SYS import pipeline as pipeline_context
from SYS.logger import debug, log
URL_API = (
"https://tidal-api.binimum.org",
"https://triton.squid.wtf",
"https://wolf.qqdl.site",
"https://maus.qqdl.site",
@@ -33,6 +32,7 @@ URL_API = (
"https://hund.qqdl.site",
"https://tidal.kinoplus.online",
"https://tidal-api.binimum.org",
"https://tidal-api.binimum.org",
)
@@ -64,7 +64,7 @@ def _format_total_seconds(seconds: Any) -> str:
return f"{mins}:{secs:02d}"
class HIFI(Provider):
class Tidal(Provider):
TABLE_AUTO_STAGES = {
"hifi.track": ["download-file"],
@@ -237,6 +237,16 @@ class HIFI(Provider):
except Exception:
return "", None
scheme = str(parsed.scheme or "").lower().strip()
if scheme == "hifi":
# Handle hifi://view/id
view = str(parsed.netloc or "").lower().strip()
path_parts = [p for p in (parsed.path or "").split("/") if p]
identifier = None
if path_parts:
identifier = self._parse_int(path_parts[0])
return view, identifier
parts = [segment for segment in (parsed.path or "").split("/") if segment]
if not parts:
return "", None
@@ -248,7 +258,7 @@ class HIFI(Provider):
return "", None
view = parts[idx].lower()
if view not in {"album", "track"}:
if view not in {"album", "track", "artist"}:
return "", None
for segment in parts[idx + 1:]:
@@ -279,6 +289,7 @@ class HIFI(Provider):
annotations=["tidal", "track"],
media_kind="audio",
full_metadata=dict(detail) if isinstance(detail, dict) else {},
selection_args=["-url", f"hifi://track/{track_id}"],
)
def _extract_artist_selection_context(self, selected_items: List[Any]) -> List[Tuple[int, str]]:
@@ -802,6 +813,11 @@ class HIFI(Provider):
full_metadata=md,
)
@staticmethod
def url_patterns() -> List[str]:
"""Return URL prefixes handled by this provider."""
return ["hifi://", "tidal.com"]
@staticmethod
def _find_ffmpeg() -> Optional[str]:
exe = shutil.which("ffmpeg")
@@ -1113,34 +1129,28 @@ class HIFI(Provider):
if isinstance(getattr(result, "full_metadata", None), dict):
md = dict(getattr(result, "full_metadata") or {})
if not md.get("manifest"):
track_id = self._extract_track_id_from_result(result)
if track_id:
detail = self._fetch_track_details(track_id)
if isinstance(detail, dict) and detail:
try:
md.update(detail)
except Exception:
md = detail
track_id = self._extract_track_id_from_result(result)
if track_id:
# Multi-part enrichment from API: metadata, tags, and lyrics.
full_data = self._fetch_all_track_data(track_id)
if isinstance(full_data, dict):
# 1. Update metadata
api_md = full_data.get("metadata")
if isinstance(api_md, dict):
md.update(api_md)
# 2. Update tags (re-sync result.tag so cmdlet sees them)
api_tags = full_data.get("tags")
if isinstance(api_tags, list) and api_tags:
result.tag = set(api_tags)
# Best-effort: fetch synced lyric subtitles for MPV (LRC).
try:
track_id_for_lyrics = self._extract_track_id_from_result(result)
except Exception:
track_id_for_lyrics = None
if track_id_for_lyrics and not md.get("_tidal_lyrics_subtitles"):
lyr = self._fetch_track_lyrics(track_id_for_lyrics)
if isinstance(lyr, dict) and lyr:
try:
md.setdefault("lyrics", lyr)
except Exception:
pass
try:
subtitles = lyr.get("subtitles")
# 3. Handle lyrics
lyrics = full_data.get("lyrics")
if isinstance(lyrics, dict) and lyrics:
md.setdefault("lyrics", lyrics)
subtitles = lyrics.get("subtitles")
if isinstance(subtitles, str) and subtitles.strip():
md["_tidal_lyrics_subtitles"] = subtitles.strip()
except Exception:
pass
# Ensure downstream cmdlets see our enriched metadata.
try:
@@ -1665,6 +1675,7 @@ class HIFI(Provider):
tag=tags,
columns=columns,
full_metadata=full_md,
selection_args=["-url", path],
)
if url_value:
try:
@@ -1739,66 +1750,34 @@ class HIFI(Provider):
return contexts
def _fetch_track_details(self, track_id: int) -> Optional[Dict[str, Any]]:
if track_id <= 0:
return None
info_data = self._fetch_track_info(track_id)
for base in self.api_urls:
endpoint = f"{base.rstrip('/')}/track/"
try:
client = self._get_api_client_for_base(base)
payload = client.track(track_id) if client else None
data = payload.get("data") if isinstance(payload, dict) else None
if isinstance(data, dict):
merged: Dict[str, Any] = {}
if isinstance(info_data, dict):
merged.update(info_data)
merged.update(data)
return merged
except Exception as exc:
log(f"[hifi] Track lookup failed for {endpoint}: {exc}", file=sys.stderr)
continue
return None
"""Legacy wrapper returning just metadata from the consolidated API call."""
res = self._fetch_all_track_data(track_id)
return res.get("metadata") if res else None
def _fetch_track_info(self, track_id: int) -> Optional[Dict[str, Any]]:
"""Legacy wrapper; now part of _fetch_all_track_data."""
return self._fetch_track_details(track_id)
def _fetch_all_track_data(self, track_id: int) -> Optional[Dict[str, Any]]:
"""Fetch full track details including metadata, tags, and lyrics from the API."""
if track_id <= 0:
return None
for base in self.api_urls:
endpoint = f"{base.rstrip('/')}/info/"
try:
client = self._get_api_client_for_base(base)
payload = client.info(track_id) if client else None
data = payload.get("data") if isinstance(payload, dict) else None
if isinstance(data, dict):
return data
if not client:
continue
# This method in the API client handles merging info+track and building tags.
return client.get_full_track_metadata(track_id)
except Exception as exc:
debug(f"[hifi] Info lookup failed for {endpoint}: {exc}")
debug(f"[hifi] Full track fetch failed for {base}: {exc}")
continue
return None
def _fetch_track_lyrics(self, track_id: int) -> Optional[Dict[str, Any]]:
if track_id <= 0:
return None
for base in self.api_urls:
endpoint = f"{base.rstrip('/')}/lyrics/"
try:
client = self._get_api_client_for_base(base)
payload = client.lyrics(track_id) if client else None
if not isinstance(payload, dict):
continue
lyrics_obj = payload.get("lyrics")
if isinstance(lyrics_obj, dict) and lyrics_obj:
return lyrics_obj
data_obj = payload.get("data")
if isinstance(data_obj, dict) and data_obj:
return data_obj
except Exception as exc:
debug(f"[hifi] Lyrics lookup failed for {endpoint}: {exc}")
continue
return None
"""Legacy wrapper returning just lyrics from the consolidated API call."""
res = self._fetch_all_track_data(track_id)
return res.get("lyrics") if res else None
def _build_track_columns(self, detail: Dict[str, Any], track_id: int) -> List[Tuple[str, str]]:
values: List[Tuple[str, str]] = [
@@ -1816,8 +1795,8 @@ class HIFI(Provider):
def _build_track_tags(self, metadata: Dict[str, Any]) -> set[str]:
return build_track_tags(metadata)
@staticmethod
def selection_auto_stage(
self,
table_type: str,
stage_args: Optional[Sequence[str]] = None,
) -> Optional[List[str]]:
@@ -2004,17 +1983,11 @@ class HIFI(Provider):
return True
# Optimization: If we are selecting tracks, do NOT force a "Detail View" (resolving manifest) here.
# This allows batch selection to flow immediately to `download-file` (via TABLE_AUTO_STAGES)
# or other downstream cmdlets. The download logic (HIFI.download) handles manifest resolution locally.
if table_type == "hifi.track" or (is_generic_hifi and any(str(get_field(i, "path")).startswith("hifi://track/") for i in selected_items)):
try:
meta = (
current_table.get_table_metadata()
if current_table is not None and hasattr(current_table, "get_table_metadata")
else {}
)
except Exception:
meta = {}
if isinstance(meta, dict) and meta.get("resolved_manifest"):
return False
return False
contexts = self._extract_track_selection_context(selected_items)
try:
@@ -2047,17 +2020,7 @@ class HIFI(Provider):
pass
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}"
resolved_path = f"hifi://track/{track_id}"
artists = self._extract_artists(detail)
artist_display = ", ".join(artists) if artists else ""
@@ -2086,6 +2049,7 @@ class HIFI(Provider):
columns=columns,
full_metadata=detail,
tag=tags,
selection_args=["-url", resolved_path],
)
if url_value:
try: