This commit is contained in:
2026-01-05 07:51:19 -08:00
parent 8545367e28
commit 1f765cffda
32 changed files with 3447 additions and 3250 deletions

View File

@@ -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())