j
This commit is contained in:
434
Provider/HIFI.py
434
Provider/HIFI.py
@@ -1,12 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from API.hifi import HifiApiClient
|
||||
from ProviderCore.base import Provider, SearchResult
|
||||
from SYS.logger import log
|
||||
from SYS.logger import debug, log
|
||||
|
||||
DEFAULT_API_URLS = (
|
||||
"https://tidal-api.binimum.org",
|
||||
@@ -27,6 +30,10 @@ _SEGMENT_BOUNDARY_RE = re.compile(r"(?=\b\w+\s*:)")
|
||||
|
||||
|
||||
class HIFI(Provider):
|
||||
|
||||
TABLE_AUTO_PREFIXES = {
|
||||
"hifi": ["download-file"],
|
||||
}
|
||||
"""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
|
||||
@@ -86,6 +93,379 @@ class HIFI(Provider):
|
||||
|
||||
return results[:limit]
|
||||
|
||||
@staticmethod
|
||||
def _safe_filename(value: Any, *, fallback: str = "hifi") -> str:
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
return fallback
|
||||
text = re.sub(r"[<>:\"/\\|?*\x00-\x1f]", "_", text)
|
||||
text = re.sub(r"\s+", " ", text).strip().strip(". ")
|
||||
return text[:120] if text else fallback
|
||||
|
||||
@staticmethod
|
||||
def _parse_track_id(value: Any) -> Optional[int]:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
track_id = int(value)
|
||||
except Exception:
|
||||
return None
|
||||
return track_id if track_id > 0 else None
|
||||
|
||||
def _extract_track_id_from_result(self, result: SearchResult) -> Optional[int]:
|
||||
md = getattr(result, "full_metadata", None)
|
||||
if isinstance(md, dict):
|
||||
track_id = self._parse_track_id(md.get("trackId") or md.get("id"))
|
||||
if track_id:
|
||||
return track_id
|
||||
|
||||
path = str(getattr(result, "path", "") or "").strip()
|
||||
if path:
|
||||
m = re.search(r"hifi:(?://)?track[\\/](\d+)", path, flags=re.IGNORECASE)
|
||||
if m:
|
||||
return self._parse_track_id(m.group(1))
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _find_ffmpeg() -> Optional[str]:
|
||||
exe = shutil.which("ffmpeg")
|
||||
if exe:
|
||||
return exe
|
||||
try:
|
||||
repo_root = Path(__file__).resolve().parents[1]
|
||||
bundled = repo_root / "MPV" / "ffmpeg" / "bin" / "ffmpeg.exe"
|
||||
if bundled.is_file():
|
||||
return str(bundled)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _find_ffprobe() -> Optional[str]:
|
||||
exe = shutil.which("ffprobe")
|
||||
if exe:
|
||||
return exe
|
||||
try:
|
||||
repo_root = Path(__file__).resolve().parents[1]
|
||||
bundled = repo_root / "MPV" / "ffmpeg" / "bin" / "ffprobe.exe"
|
||||
if bundled.is_file():
|
||||
return str(bundled)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
def _probe_audio_codec(self, input_ref: str) -> Optional[str]:
|
||||
"""Best-effort probe for primary audio codec name (lowercase)."""
|
||||
|
||||
candidate = str(input_ref or "").strip()
|
||||
if not candidate:
|
||||
return None
|
||||
|
||||
ffprobe_path = self._find_ffprobe()
|
||||
if ffprobe_path:
|
||||
cmd = [
|
||||
ffprobe_path,
|
||||
"-v",
|
||||
"error",
|
||||
"-select_streams",
|
||||
"a:0",
|
||||
"-show_entries",
|
||||
"stream=codec_name",
|
||||
"-of",
|
||||
"default=nw=1:nk=1",
|
||||
candidate,
|
||||
]
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if proc.returncode == 0:
|
||||
codec = str(proc.stdout or "").strip().lower()
|
||||
if codec:
|
||||
return codec
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback: parse `ffmpeg -i` stream info.
|
||||
ffmpeg_path = self._find_ffmpeg()
|
||||
if not ffmpeg_path:
|
||||
return None
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[ffmpeg_path, "-hide_banner", "-i", candidate],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
text = (proc.stderr or "") + "\n" + (proc.stdout or "")
|
||||
m = re.search(r"Audio:\s*([A-Za-z0-9_]+)", text)
|
||||
if m:
|
||||
return str(m.group(1)).strip().lower()
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _preferred_audio_suffix(codec: Optional[str], metadata: Optional[Dict[str, Any]] = None) -> str:
|
||||
c = str(codec or "").strip().lower()
|
||||
if c == "flac":
|
||||
return ".flac"
|
||||
if c in {"aac", "alac"}:
|
||||
return ".m4a"
|
||||
# Default to Matroska Audio for unknown / uncommon codecs.
|
||||
return ".mka"
|
||||
|
||||
@staticmethod
|
||||
def _has_nonempty_file(path: Path) -> bool:
|
||||
try:
|
||||
return path.is_file() and path.stat().st_size > 0
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _ffmpeg_demux_to_audio(
|
||||
self,
|
||||
*,
|
||||
input_ref: str,
|
||||
output_path: Path,
|
||||
lossless_fallback: bool = True,
|
||||
) -> Optional[Path]:
|
||||
ffmpeg_path = self._find_ffmpeg()
|
||||
if not ffmpeg_path:
|
||||
debug("[hifi] ffmpeg not found; cannot materialize audio from MPD")
|
||||
return None
|
||||
|
||||
if self._has_nonempty_file(output_path):
|
||||
return output_path
|
||||
|
||||
try:
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
protocol_whitelist = "file,https,http,tcp,tls,crypto,data"
|
||||
|
||||
def _run(cmd: List[str]) -> bool:
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if proc.returncode == 0 and self._has_nonempty_file(output_path):
|
||||
return True
|
||||
if proc.stderr:
|
||||
debug(f"[hifi] ffmpeg failed: {proc.stderr.strip()}")
|
||||
except Exception as exc:
|
||||
debug(f"[hifi] ffmpeg invocation failed: {exc}")
|
||||
return False
|
||||
|
||||
# Prefer remux (fast, no transcode).
|
||||
cmd_copy = [
|
||||
ffmpeg_path,
|
||||
"-y",
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-protocol_whitelist",
|
||||
protocol_whitelist,
|
||||
"-i",
|
||||
str(input_ref),
|
||||
"-vn",
|
||||
"-c",
|
||||
"copy",
|
||||
str(output_path),
|
||||
]
|
||||
if _run(cmd_copy):
|
||||
return output_path
|
||||
|
||||
if not lossless_fallback:
|
||||
return None
|
||||
|
||||
# Fallback: decode/transcode to FLAC to guarantee a supported file.
|
||||
flac_path = (
|
||||
output_path
|
||||
if output_path.suffix.lower() == ".flac"
|
||||
else output_path.with_suffix(".flac")
|
||||
)
|
||||
if self._has_nonempty_file(flac_path):
|
||||
return flac_path
|
||||
|
||||
# Avoid leaving a partial FLAC behind if we're transcoding into the final name.
|
||||
tmp_flac_path = flac_path
|
||||
if flac_path == output_path:
|
||||
tmp_flac_path = output_path.with_name(f"{output_path.stem}.tmp{output_path.suffix}")
|
||||
|
||||
cmd_flac = [
|
||||
ffmpeg_path,
|
||||
"-y",
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-protocol_whitelist",
|
||||
protocol_whitelist,
|
||||
"-i",
|
||||
str(input_ref),
|
||||
"-vn",
|
||||
"-c:a",
|
||||
"flac",
|
||||
str(tmp_flac_path),
|
||||
]
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
cmd_flac,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if proc.returncode == 0 and self._has_nonempty_file(tmp_flac_path):
|
||||
if tmp_flac_path != flac_path:
|
||||
try:
|
||||
tmp_flac_path.replace(flac_path)
|
||||
except Exception:
|
||||
# If rename fails, still return the temp file.
|
||||
return tmp_flac_path
|
||||
return flac_path
|
||||
if proc.stderr:
|
||||
debug(f"[hifi] ffmpeg flac fallback failed: {proc.stderr.strip()}")
|
||||
except Exception as exc:
|
||||
debug(f"[hifi] ffmpeg flac fallback invocation failed: {exc}")
|
||||
return None
|
||||
|
||||
def download(self, result: SearchResult, output_dir: Path) -> Optional[Path]:
|
||||
"""Materialize a playable audio file from a Tidal DASH manifest."""
|
||||
|
||||
try:
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
raw_path = str(getattr(result, "path", "") or "").strip()
|
||||
|
||||
md: Dict[str, Any] = {}
|
||||
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
|
||||
|
||||
# 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")
|
||||
if isinstance(subtitles, str) and subtitles.strip():
|
||||
md["_tidal_lyrics_subtitles"] = subtitles.strip()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Ensure downstream cmdlets see our enriched metadata.
|
||||
try:
|
||||
if isinstance(getattr(result, "full_metadata", None), dict):
|
||||
result.full_metadata.update(md)
|
||||
else:
|
||||
result.full_metadata = md
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
from cmdlet._shared import resolve_tidal_manifest_path
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
resolved = resolve_tidal_manifest_path({"full_metadata": md, "path": raw_path, "title": getattr(result, "title", "")})
|
||||
if not resolved:
|
||||
return None
|
||||
|
||||
resolved_text = str(resolved).strip()
|
||||
if not resolved_text:
|
||||
return None
|
||||
|
||||
track_id = self._extract_track_id_from_result(result)
|
||||
title_part = self._safe_filename(getattr(result, "title", None), fallback="hifi")
|
||||
hash_part = self._safe_filename(md.get("manifestHash"), fallback="")
|
||||
|
||||
stem_parts = ["hifi"]
|
||||
if track_id:
|
||||
stem_parts.append(str(track_id))
|
||||
if hash_part:
|
||||
stem_parts.append(hash_part[:12])
|
||||
if title_part:
|
||||
stem_parts.append(title_part)
|
||||
stem = "-".join([p for p in stem_parts if p])[:180].rstrip("- ")
|
||||
|
||||
codec = self._probe_audio_codec(resolved_text)
|
||||
suffix = self._preferred_audio_suffix(codec, md)
|
||||
|
||||
# If resolve_tidal_manifest_path returned a URL, prefer feeding it directly to ffmpeg.
|
||||
if resolved_text.lower().startswith("http"):
|
||||
out_file = output_dir / f"{stem}{suffix}"
|
||||
materialized = self._ffmpeg_demux_to_audio(input_ref=resolved_text, output_path=out_file)
|
||||
if materialized is not None:
|
||||
return materialized
|
||||
|
||||
# As a fallback, try downloading the URL directly if it looks like a file.
|
||||
try:
|
||||
import httpx
|
||||
|
||||
resp = httpx.get(resolved_text, timeout=float(getattr(self, "api_timeout", 10.0)))
|
||||
resp.raise_for_status()
|
||||
content = resp.content
|
||||
direct_path = output_dir / f"{stem}.bin"
|
||||
with open(direct_path, "wb") as fh:
|
||||
fh.write(content)
|
||||
return direct_path
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
try:
|
||||
source_path = Path(resolved_text)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
if source_path.is_file() and source_path.suffix.lower() == ".mpd":
|
||||
# Materialize audio from the local MPD.
|
||||
out_file = output_dir / f"{stem}{suffix}"
|
||||
materialized = self._ffmpeg_demux_to_audio(input_ref=str(source_path), output_path=out_file)
|
||||
if materialized is not None:
|
||||
return materialized
|
||||
return None
|
||||
|
||||
# If we somehow got a local audio file already, copy it to output_dir.
|
||||
if source_path.is_file() and source_path.suffix.lower() in {".m4a", ".mp3", ".flac", ".wav", ".mka", ".mp4"}:
|
||||
dest = output_dir / f"{stem}{source_path.suffix.lower()}"
|
||||
if self._has_nonempty_file(dest):
|
||||
return dest
|
||||
try:
|
||||
shutil.copyfile(source_path, dest)
|
||||
return dest
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
# As a last resort, attempt to treat the local path as an ffmpeg input.
|
||||
out_file = output_dir / f"{stem}{suffix}"
|
||||
materialized = self._ffmpeg_demux_to_audio(input_ref=resolved_text, output_path=out_file)
|
||||
return materialized
|
||||
|
||||
def _get_api_client_for_base(self, base_url: str) -> Optional[HifiApiClient]:
|
||||
base = base_url.rstrip("/")
|
||||
for client in self.api_clients:
|
||||
@@ -126,6 +506,8 @@ class HIFI(Provider):
|
||||
deduped: List[Dict[str, Any]] = []
|
||||
for item in items:
|
||||
track_id = item.get("id") or item.get("trackId")
|
||||
if track_id is None:
|
||||
continue
|
||||
try:
|
||||
track_int = int(track_id)
|
||||
except Exception:
|
||||
@@ -381,6 +763,29 @@ class HIFI(Provider):
|
||||
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
|
||||
|
||||
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)),
|
||||
@@ -405,6 +810,27 @@ class HIFI(Provider):
|
||||
if not stage_is_last:
|
||||
return False
|
||||
|
||||
try:
|
||||
current_table = ctx.get_current_stage_table()
|
||||
except Exception:
|
||||
current_table = None
|
||||
table_type = (
|
||||
current_table.table
|
||||
if current_table and hasattr(current_table, "table")
|
||||
else None
|
||||
)
|
||||
if isinstance(table_type, str) and table_type.strip().lower() == "hifi.track":
|
||||
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
|
||||
|
||||
contexts = self._extract_track_selection_context(selected_items)
|
||||
if not contexts:
|
||||
return False
|
||||
@@ -426,6 +852,10 @@ class HIFI(Provider):
|
||||
|
||||
table = ResultTable("HIFI Track").set_preserve_order(True)
|
||||
table.set_table("hifi.track")
|
||||
try:
|
||||
table.set_table_metadata({"provider": "hifi", "view": "track", "resolved_manifest": True})
|
||||
except Exception:
|
||||
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.
|
||||
@@ -455,7 +885,7 @@ class HIFI(Provider):
|
||||
columns.insert(insert_pos, ("Album", album_title))
|
||||
|
||||
result = SearchResult(
|
||||
table="hifi.track",
|
||||
table="hifi",
|
||||
title=title,
|
||||
path=resolved_path,
|
||||
detail=f"id:{track_id}",
|
||||
|
||||
Reference in New Issue
Block a user