df
This commit is contained in:
345
Provider/HIFI.py
345
Provider/HIFI.py
@@ -1,12 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import shutil
|
||||
import string
|
||||
import subprocess
|
||||
import time
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from typing import Any, Dict, Iterable, List, Optional, Tuple
|
||||
from API.hifi import HifiApiClient
|
||||
from ProviderCore.base import Provider, SearchResult
|
||||
from SYS.logger import debug, log
|
||||
@@ -733,6 +736,10 @@ class HIFI(Provider):
|
||||
input_ref: str,
|
||||
output_path: Path,
|
||||
lossless_fallback: bool = True,
|
||||
progress: Optional[Any] = None,
|
||||
transfer_label: Optional[str] = None,
|
||||
duration_seconds: Optional[int] = None,
|
||||
audio_quality: Optional[str] = None,
|
||||
) -> Optional[Path]:
|
||||
ffmpeg_path = self._find_ffmpeg()
|
||||
if not ffmpeg_path:
|
||||
@@ -749,20 +756,115 @@ class HIFI(Provider):
|
||||
|
||||
protocol_whitelist = "file,https,http,tcp,tls,crypto,data"
|
||||
|
||||
def _run(cmd: List[str]) -> bool:
|
||||
label = str(transfer_label or output_path.name or "hifi")
|
||||
|
||||
def _estimate_total_bytes() -> Optional[int]:
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
dur = int(duration_seconds) if duration_seconds is not None else None
|
||||
except Exception:
|
||||
dur = None
|
||||
if not dur or dur <= 0:
|
||||
return None
|
||||
|
||||
qual = str(audio_quality or "").strip().lower()
|
||||
# Rough per-quality bitrate guess (bytes/sec).
|
||||
if qual in {"hi_res",
|
||||
"hi_res_lossless",
|
||||
"hires",
|
||||
"hi-res",
|
||||
"master",
|
||||
"mqa"}:
|
||||
bps = 4_608_000 # ~24-bit/96k stereo
|
||||
elif qual in {"lossless",
|
||||
"flac"}:
|
||||
bps = 1_411_200 # 16-bit/44.1k stereo
|
||||
else:
|
||||
bps = 320_000 # kbps for compressed
|
||||
|
||||
try:
|
||||
return int((bps / 8.0) * dur)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
est_total_bytes = _estimate_total_bytes()
|
||||
|
||||
def _update_transfer(total_bytes_val: Optional[int]) -> None:
|
||||
if progress is None:
|
||||
return
|
||||
try:
|
||||
progress.update_transfer(
|
||||
label=label,
|
||||
completed=int(total_bytes_val) if total_bytes_val is not None else None,
|
||||
total=est_total_bytes,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _run(cmd: List[str], *, target_path: Optional[Path] = None) -> bool:
|
||||
cmd_progress = list(cmd)
|
||||
# Enable ffmpeg progress output for live byte updates.
|
||||
cmd_progress.insert(1, "-progress")
|
||||
cmd_progress.insert(2, "pipe:1")
|
||||
cmd_progress.insert(3, "-nostats")
|
||||
|
||||
try:
|
||||
proc = subprocess.Popen(
|
||||
cmd_progress,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
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
|
||||
|
||||
last_bytes = None
|
||||
try:
|
||||
while True:
|
||||
line = proc.stdout.readline() if proc.stdout else ""
|
||||
if not line:
|
||||
if proc.poll() is not None:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
continue
|
||||
|
||||
if "=" not in line:
|
||||
continue
|
||||
key, val = line.strip().split("=", 1)
|
||||
if key == "total_size":
|
||||
try:
|
||||
last_bytes = int(val)
|
||||
_update_transfer(last_bytes)
|
||||
except Exception:
|
||||
pass
|
||||
elif key == "out_time_ms":
|
||||
# Map out_time_ms to byte estimate when total_size missing.
|
||||
try:
|
||||
if est_total_bytes and val.isdigit():
|
||||
ms = int(val)
|
||||
dur_ms = (duration_seconds or 0) * 1000
|
||||
if dur_ms > 0:
|
||||
pct = min(1.0, max(0.0, ms / dur_ms))
|
||||
approx = int(est_total_bytes * pct)
|
||||
_update_transfer(approx)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
proc.wait()
|
||||
finally:
|
||||
if last_bytes is not None:
|
||||
_update_transfer(last_bytes)
|
||||
|
||||
check_path = target_path or output_path
|
||||
if proc.returncode == 0 and self._has_nonempty_file(check_path):
|
||||
return True
|
||||
|
||||
try:
|
||||
stderr_text = proc.stderr.read() if proc.stderr else ""
|
||||
if stderr_text:
|
||||
debug(f"[hifi] ffmpeg failed: {stderr_text.strip()}")
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
# Prefer remux (fast, no transcode).
|
||||
@@ -816,25 +918,14 @@ class HIFI(Provider):
|
||||
"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}")
|
||||
if _run(cmd_flac, target_path=tmp_flac_path) 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
|
||||
return None
|
||||
|
||||
def download(self, result: SearchResult, output_dir: Path) -> Optional[Path]:
|
||||
@@ -921,7 +1012,14 @@ class HIFI(Provider):
|
||||
# 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)
|
||||
materialized = self._ffmpeg_demux_to_audio(
|
||||
input_ref=resolved_text,
|
||||
output_path=out_file,
|
||||
progress=self.config.get("_pipeline_progress") if isinstance(self.config, dict) else None,
|
||||
transfer_label=title_part or getattr(result, "title", None),
|
||||
duration_seconds=self._coerce_duration_seconds(md),
|
||||
audio_quality=md.get("audioQuality") if isinstance(md, dict) else None,
|
||||
)
|
||||
if materialized is not None:
|
||||
return materialized
|
||||
|
||||
@@ -947,7 +1045,14 @@ class HIFI(Provider):
|
||||
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)
|
||||
materialized = self._ffmpeg_demux_to_audio(
|
||||
input_ref=str(source_path),
|
||||
output_path=out_file,
|
||||
progress=self.config.get("_pipeline_progress") if isinstance(self.config, dict) else None,
|
||||
transfer_label=title_part or getattr(result, "title", None),
|
||||
duration_seconds=self._coerce_duration_seconds(md),
|
||||
audio_quality=md.get("audioQuality") if isinstance(md, dict) else None,
|
||||
)
|
||||
if materialized is not None:
|
||||
return materialized
|
||||
return None
|
||||
@@ -965,7 +1070,14 @@ class HIFI(Provider):
|
||||
|
||||
# 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)
|
||||
materialized = self._ffmpeg_demux_to_audio(
|
||||
input_ref=resolved_text,
|
||||
output_path=out_file,
|
||||
progress=self.config.get("_pipeline_progress") if isinstance(self.config, dict) else None,
|
||||
transfer_label=title_part or getattr(result, "title", None),
|
||||
duration_seconds=self._coerce_duration_seconds(md),
|
||||
audio_quality=md.get("audioQuality") if isinstance(md, dict) else None,
|
||||
)
|
||||
return materialized
|
||||
|
||||
def _get_api_client_for_base(self, base_url: str) -> Optional[HifiApiClient]:
|
||||
@@ -1228,6 +1340,38 @@ class HIFI(Provider):
|
||||
minutes, secs = divmod(total, 60)
|
||||
return f"{minutes}:{secs:02d}"
|
||||
|
||||
@staticmethod
|
||||
def _coerce_duration_seconds(value: Any) -> Optional[int]:
|
||||
candidates = []
|
||||
candidates.append(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
|
||||
if isinstance(cand, str) and cand.strip().endswith("ms"):
|
||||
cand = cand.strip()[:-2]
|
||||
v = float(cand)
|
||||
if v <= 0:
|
||||
continue
|
||||
if v > 10_000: # treat as milliseconds
|
||||
v = v / 1000.0
|
||||
return int(round(v))
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _stringify(value: Any) -> str:
|
||||
text = str(value or "").strip()
|
||||
@@ -1305,23 +1449,18 @@ class HIFI(Provider):
|
||||
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())
|
||||
|
||||
# IMPORTANT: do not retain a shared reference to the raw API dict.
|
||||
# Downstream playback (MPV) mutates metadata to cache the decoded Tidal
|
||||
# manifest path/URL. If multiple results share the same dict reference,
|
||||
# they can incorrectly collapse to a single playable target.
|
||||
full_md: Dict[str, Any] = dict(item)
|
||||
url_value = self._stringify(full_md.get("url"))
|
||||
if url_value:
|
||||
full_md["url"] = url_value
|
||||
|
||||
return SearchResult(
|
||||
tags = self._build_track_tags(full_md)
|
||||
|
||||
result = SearchResult(
|
||||
table="hifi",
|
||||
title=title,
|
||||
path=path,
|
||||
@@ -1332,6 +1471,12 @@ class HIFI(Provider):
|
||||
columns=columns,
|
||||
full_metadata=full_md,
|
||||
)
|
||||
if url_value:
|
||||
try:
|
||||
result.url = url_value
|
||||
except Exception:
|
||||
pass
|
||||
return result
|
||||
|
||||
def _extract_track_selection_context(
|
||||
self, selected_items: List[Any]
|
||||
@@ -1401,6 +1546,9 @@ class HIFI(Provider):
|
||||
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:
|
||||
@@ -1408,12 +1556,32 @@ class HIFI(Provider):
|
||||
payload = client.track(track_id) if client else None
|
||||
data = payload.get("data") if isinstance(payload, dict) else None
|
||||
if isinstance(data, dict):
|
||||
return data
|
||||
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
|
||||
|
||||
def _fetch_track_info(self, track_id: int) -> Optional[Dict[str, Any]]:
|
||||
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
|
||||
except Exception as exc:
|
||||
debug(f"[hifi] Info lookup failed for {endpoint}: {exc}")
|
||||
continue
|
||||
return None
|
||||
|
||||
def _fetch_track_lyrics(self, track_id: int) -> Optional[Dict[str, Any]]:
|
||||
if track_id <= 0:
|
||||
return None
|
||||
@@ -1450,6 +1618,54 @@ class HIFI(Provider):
|
||||
]
|
||||
return [(name, value) for name, value in values if value]
|
||||
|
||||
def _build_track_tags(self, metadata: Dict[str, Any]) -> set[str]:
|
||||
tags: set[str] = {"tidal"}
|
||||
|
||||
audio_quality = self._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 = self._stringify(metadata.get("title"))
|
||||
if title_text:
|
||||
tags.add(f"title:{title_text}")
|
||||
|
||||
artists = self._extract_artists(metadata)
|
||||
for artist in artists:
|
||||
artist_clean = self._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 = self._stringify(album_obj.get("title"))
|
||||
else:
|
||||
album_title = self._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 = self._stringify(track_no_val)
|
||||
if track_text:
|
||||
tags.add(f"track:{track_text}")
|
||||
|
||||
return tags
|
||||
|
||||
def selector(
|
||||
self,
|
||||
selected_items: List[Any],
|
||||
@@ -1476,16 +1692,32 @@ class HIFI(Provider):
|
||||
else None
|
||||
)
|
||||
|
||||
try:
|
||||
debug(
|
||||
f"[hifi.selector] table_type={table_type} stage_is_last={stage_is_last} selected_count={len(selected_items) if selected_items else 0}"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Artist selection: selecting @N should open an albums list.
|
||||
if isinstance(table_type, str) and table_type.strip().lower() == "hifi.artist":
|
||||
contexts = self._extract_artist_selection_context(selected_items)
|
||||
try:
|
||||
debug(f"[hifi.selector] artist contexts={len(contexts)}")
|
||||
except Exception:
|
||||
pass
|
||||
if not contexts:
|
||||
return False
|
||||
|
||||
artist_id, artist_name = contexts[0]
|
||||
album_results = self._albums_for_artist(artist_id=artist_id, artist_name=artist_name, limit=200)
|
||||
if not album_results:
|
||||
return False
|
||||
try:
|
||||
from SYS.rich_display import stdout_console
|
||||
stdout_console().print(f"[bold yellow][hifi] No albums found for {artist_name}[/]")
|
||||
except Exception:
|
||||
log(f"[hifi] No albums found for {artist_name}")
|
||||
return True
|
||||
|
||||
try:
|
||||
from SYS.rich_display import stdout_console
|
||||
@@ -1531,6 +1763,10 @@ class HIFI(Provider):
|
||||
# Album selection: selecting @N should open the track list for that album.
|
||||
if isinstance(table_type, str) and table_type.strip().lower() == "hifi.album":
|
||||
contexts = self._extract_album_selection_context(selected_items)
|
||||
try:
|
||||
debug(f"[hifi.selector] album contexts={len(contexts)}")
|
||||
except Exception:
|
||||
pass
|
||||
if not contexts:
|
||||
return False
|
||||
|
||||
@@ -1605,6 +1841,10 @@ class HIFI(Provider):
|
||||
return False
|
||||
|
||||
contexts = self._extract_track_selection_context(selected_items)
|
||||
try:
|
||||
debug(f"[hifi.selector] track contexts={len(contexts)}")
|
||||
except Exception:
|
||||
pass
|
||||
if not contexts:
|
||||
return False
|
||||
|
||||
@@ -1657,6 +1897,9 @@ class HIFI(Provider):
|
||||
insert_pos = 2 if artist_display else 1
|
||||
columns.insert(insert_pos, ("Album", album_title))
|
||||
|
||||
tags = self._build_track_tags(detail)
|
||||
url_value = self._stringify(detail.get("url"))
|
||||
|
||||
result = SearchResult(
|
||||
table="hifi",
|
||||
title=title,
|
||||
@@ -1666,7 +1909,13 @@ class HIFI(Provider):
|
||||
media_kind="audio",
|
||||
columns=columns,
|
||||
full_metadata=detail,
|
||||
tag=tags,
|
||||
)
|
||||
if url_value:
|
||||
try:
|
||||
result.url = url_value
|
||||
except Exception:
|
||||
pass
|
||||
table.add_result(result)
|
||||
try:
|
||||
results_payload.append(result.to_dict())
|
||||
|
||||
Reference in New Issue
Block a user