f
This commit is contained in:
166
Provider/HIFI.py
166
Provider/HIFI.py
@@ -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:
|
||||
|
||||
2119
Provider/Tidal.py
Normal file
2119
Provider/Tidal.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -12,10 +12,10 @@ import subprocess
|
||||
from API.HTTP import HTTPClient
|
||||
from ProviderCore.base import SearchResult
|
||||
try:
|
||||
from Provider.HIFI import HIFI
|
||||
from Provider.Tidal import Tidal
|
||||
except ImportError: # pragma: no cover - optional
|
||||
HIFI = None
|
||||
from Provider.tidal_shared import (
|
||||
Tidal = None
|
||||
from API.Tidal import (
|
||||
build_track_tags,
|
||||
extract_artists,
|
||||
stringify,
|
||||
@@ -1426,17 +1426,17 @@ except Exception:
|
||||
# Registry ---------------------------------------------------------------
|
||||
|
||||
class TidalMetadataProvider(MetadataProvider):
|
||||
"""Metadata provider that reuses the HIFI search provider for tidal info."""
|
||||
"""Metadata provider that reuses the Tidal search provider for tidal info."""
|
||||
|
||||
@property
|
||||
def name(self) -> str: # type: ignore[override]
|
||||
return "tidal"
|
||||
|
||||
def __init__(self, config: Optional[Dict[str, Any]] = None) -> None:
|
||||
if HIFI is None:
|
||||
raise RuntimeError("HIFI provider unavailable for tidal metadata")
|
||||
if Tidal is None:
|
||||
raise RuntimeError("Tidal provider unavailable for tidal metadata")
|
||||
super().__init__(config)
|
||||
self._provider = HIFI(self.config)
|
||||
self._provider = Tidal(self.config)
|
||||
|
||||
def search(self, query: str, limit: int = 10) -> List[Dict[str, Any]]:
|
||||
normalized = str(query or "").strip()
|
||||
|
||||
@@ -236,7 +236,7 @@ class Soulseek(Provider):
|
||||
if not stage_is_last:
|
||||
return False
|
||||
|
||||
# If we wanted to handle drill-down (like HIFI.py) we would:
|
||||
# If we wanted to handle drill-down (like Tidal.py) we would:
|
||||
# 1. Fetch more data (e.g. user shares)
|
||||
# 2. Create a new ResultTable
|
||||
# 3. ctx.set_current_stage_table(new_table)
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional, Set
|
||||
|
||||
|
||||
def stringify(value: Any) -> str:
|
||||
text = str(value or "").strip()
|
||||
return text
|
||||
|
||||
|
||||
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 = stringify(artist.get("name"))
|
||||
if name and name not in names:
|
||||
names.append(name)
|
||||
if not names:
|
||||
primary = item.get("artist")
|
||||
if isinstance(primary, dict):
|
||||
name = stringify(primary.get("name"))
|
||||
if name:
|
||||
names.append(name)
|
||||
return names
|
||||
|
||||
|
||||
def build_track_tags(metadata: Dict[str, Any]) -> Set[str]:
|
||||
tags: Set[str] = {"tidal"}
|
||||
|
||||
audio_quality = stringify(metadata.get("audioQuality"))
|
||||
if audio_quality:
|
||||
tags.add(f"quality:{audio_quality.lower()}")
|
||||
|
||||
media_md = metadata.get("mediaMetadata")
|
||||
if isinstance(media_md, dict):
|
||||
tag_values = media_md.get("tags") or []
|
||||
for tag in tag_values:
|
||||
if isinstance(tag, str):
|
||||
candidate = tag.strip()
|
||||
if candidate:
|
||||
tags.add(candidate.lower())
|
||||
|
||||
title_text = stringify(metadata.get("title"))
|
||||
if title_text:
|
||||
tags.add(f"title:{title_text}")
|
||||
|
||||
artists = extract_artists(metadata)
|
||||
for artist in artists:
|
||||
artist_clean = stringify(artist)
|
||||
if artist_clean:
|
||||
tags.add(f"artist:{artist_clean}")
|
||||
|
||||
album_title = ""
|
||||
album_obj = metadata.get("album")
|
||||
if isinstance(album_obj, dict):
|
||||
album_title = stringify(album_obj.get("title"))
|
||||
else:
|
||||
album_title = stringify(metadata.get("album"))
|
||||
if album_title:
|
||||
tags.add(f"album:{album_title}")
|
||||
|
||||
track_no_val = metadata.get("trackNumber") or metadata.get("track_number")
|
||||
if track_no_val is not None:
|
||||
try:
|
||||
track_int = int(track_no_val)
|
||||
if track_int > 0:
|
||||
tags.add(f"track:{track_int}")
|
||||
except Exception:
|
||||
track_text = stringify(track_no_val)
|
||||
if track_text:
|
||||
tags.add(f"track:{track_text}")
|
||||
|
||||
return tags
|
||||
|
||||
|
||||
def coerce_duration_seconds(value: Any) -> Optional[int]:
|
||||
candidates = [value]
|
||||
try:
|
||||
if isinstance(value, dict):
|
||||
for key in (
|
||||
"duration",
|
||||
"durationSeconds",
|
||||
"duration_sec",
|
||||
"duration_ms",
|
||||
"durationMillis",
|
||||
):
|
||||
if key in value:
|
||||
candidates.append(value.get(key))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for cand in candidates:
|
||||
try:
|
||||
if cand is None:
|
||||
continue
|
||||
text = str(cand).strip()
|
||||
if text.lower().endswith("ms"):
|
||||
text = text[:-2].strip()
|
||||
num = float(text)
|
||||
if num <= 0:
|
||||
continue
|
||||
if num > 10_000:
|
||||
num = num / 1000.0
|
||||
return int(round(num))
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
Reference in New Issue
Block a user