This commit is contained in:
2026-01-07 05:09:59 -08:00
parent edc33f4528
commit f0799191ff
10 changed files with 956 additions and 353 deletions

View File

@@ -10,14 +10,34 @@ import time
import sys
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
from ProviderCore.base import Provider, SearchResult, parse_inline_query_arguments
from Provider.tidal_shared import (
build_track_tags,
coerce_duration_seconds,
extract_artists,
stringify,
)
from SYS import pipeline as pipeline_context
from SYS.logger import debug, log
DEFAULT_API_URLS = (
URL_API = (
"https://tidal-api.binimum.org",
"https://triton.squid.wtf",
"https://wolf.qqdl.site",
"https://maus.qqdl.site",
"https://vogel.qqdl.site",
"https://katze.qqdl.site",
"https://hund.qqdl.site",
"https://tidal.kinoplus.online",
"https://tidal-api.binimum.org",
)
_KEY_TO_PARAM: Dict[str, str] = {
"album": "al",
"artist": "a",
@@ -49,6 +69,20 @@ class HIFI(Provider):
TABLE_AUTO_STAGES = {
"hifi.track": ["download-file"],
}
QUERY_ARG_CHOICES = {
"album": (),
"artist": (),
"playlist": (),
"track": (),
"title": (),
"video": (),
}
INLINE_QUERY_FIELD_CHOICES = QUERY_ARG_CHOICES
URL_DOMAINS = (
"tidal.com",
"listen.tidal.com",
)
URL = URL_DOMAINS
"""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
@@ -65,6 +99,14 @@ class HIFI(Provider):
self.api_timeout = 10.0
self.api_clients = [HifiApiClient(base_url=url, timeout=self.api_timeout) for url in self.api_urls]
def extract_query_arguments(self, query: str) -> Tuple[str, Dict[str, Any]]:
normalized, parsed = parse_inline_query_arguments(query)
filtered: Dict[str, Any] = {}
for key, value in parsed.items():
if key in self.QUERY_ARG_CHOICES:
filtered[key] = value
return normalized, filtered
def validate(self) -> bool:
return bool(self.api_urls)
@@ -77,8 +119,16 @@ class HIFI(Provider):
) -> List[SearchResult]:
if limit <= 0:
return []
view = self._get_view_from_query(query)
params = self._build_search_params(query)
normalized_query, inline_args = self.extract_query_arguments(query)
raw_query = str(query or "").strip()
search_query = normalized_query or raw_query
if not search_query and inline_args:
search_query = " ".join(f"{k}:{v}" for k, v in inline_args.items())
if not search_query:
return []
view = self._determine_view(search_query, inline_args)
params = self._build_search_params(search_query)
if not params:
return []
@@ -126,6 +176,18 @@ class HIFI(Provider):
return "album"
return "track"
def _determine_view(self, query: str, inline_args: Dict[str, Any]) -> str:
if inline_args:
if "artist" in inline_args:
return "artist"
if "album" in inline_args:
return "album"
if "track" in inline_args or "title" in inline_args:
return "track"
if "video" in inline_args or "playlist" in inline_args:
return "track"
return self._get_view_from_query(query)
@staticmethod
def _safe_filename(value: Any, *, fallback: str = "hifi") -> str:
text = str(value or "").strip()
@@ -169,6 +231,56 @@ class HIFI(Provider):
return None
return num if num > 0 else None
def _parse_tidal_url(self, url: str) -> Tuple[str, Optional[int]]:
try:
parsed = urlparse(str(url))
except Exception:
return "", None
parts = [segment for segment in (parsed.path or "").split("/") if segment]
if not parts:
return "", None
idx = 0
if parts[0].lower() == "browse":
idx = 1
if idx >= len(parts):
return "", None
view = parts[idx].lower()
if view not in {"album", "track"}:
return "", None
for segment in parts[idx + 1:]:
identifier = self._parse_int(segment)
if identifier is not None:
return view, identifier
return view, None
def _track_detail_to_result(self, detail: Optional[Dict[str, Any]], track_id: int) -> SearchResult:
if isinstance(detail, dict):
candidate = self._item_to_result(detail)
if candidate is not None:
try:
candidate.full_metadata = dict(detail)
except Exception:
pass
return candidate
title = f"Track {track_id}"
if isinstance(detail, dict):
title = self._stringify(detail.get("title")) or title
return SearchResult(
table="hifi",
title=title,
path=f"hifi://track/{track_id}",
detail=f"id:{track_id}",
annotations=["tidal", "track"],
media_kind="audio",
full_metadata=dict(detail) if isinstance(detail, dict) else {},
)
def _extract_artist_selection_context(self, selected_items: List[Any]) -> List[Tuple[int, str]]:
contexts: List[Tuple[int, str]] = []
seen: set[int] = set()
@@ -589,6 +701,65 @@ class HIFI(Provider):
return results
def _present_album_tracks(
self,
track_results: List[SearchResult],
*,
album_id: Optional[int],
album_title: str,
artist_name: str,
) -> None:
if not track_results:
return
try:
from SYS.rich_display import stdout_console
from SYS.result_table import ResultTable
except Exception:
return
label = album_title or "Album"
if artist_name:
label = f"{artist_name} - {label}"
table = ResultTable(f"HIFI Tracks: {label}").set_preserve_order(True)
table.set_table("hifi.track")
try:
table.set_table_metadata(
{
"provider": "hifi",
"view": "track",
"album_id": album_id,
"album_title": album_title,
"artist_name": artist_name,
}
)
except Exception:
pass
results_payload: List[Dict[str, Any]] = []
for result in track_results:
table.add_result(result)
try:
results_payload.append(result.to_dict())
except Exception:
results_payload.append(
{
"table": getattr(result, "table", "hifi.track"),
"title": getattr(result, "title", ""),
"path": getattr(result, "path", ""),
}
)
pipeline_context.set_last_result_table(table, results_payload)
pipeline_context.set_current_stage_table(table)
try:
stdout_console().print()
stdout_console().print(table)
except Exception:
pass
def _album_item_to_result(self, album: Dict[str, Any], *, artist_name: str) -> Optional[SearchResult]:
if not isinstance(album, dict):
return None
@@ -1080,6 +1251,73 @@ class HIFI(Provider):
)
return materialized
def handle_url(self, url: str, *, output_dir: Optional[Path] = None) -> Tuple[bool, Optional[Path]]:
view, identifier = self._parse_tidal_url(url)
if not view:
return False, None
if view == "track":
if not identifier or output_dir is None:
return False, None
try:
detail = self._fetch_track_details(identifier)
except Exception:
detail = None
result = self._track_detail_to_result(detail, identifier)
try:
downloaded = self.download(result, output_dir)
except Exception:
return False, None
if downloaded:
return True, downloaded
return False, None
if view == "album":
if not identifier:
return False, None
try:
track_results = self._tracks_for_album(
album_id=identifier,
album_title="",
artist_name="",
limit=200,
)
except Exception:
return False, None
if not track_results:
return False, None
album_title = ""
artist_name = ""
metadata = getattr(track_results[0], "full_metadata", None)
if isinstance(metadata, dict):
album_obj = metadata.get("album")
if isinstance(album_obj, dict):
album_title = self._stringify(album_obj.get("title"))
else:
album_title = self._stringify(album_obj or metadata.get("album"))
artists = self._extract_artists(metadata)
if artists:
artist_name = artists[0]
if not album_title:
album_title = f"Album {identifier}"
self._present_album_tracks(
track_results,
album_id=identifier,
album_title=album_title,
artist_name=artist_name,
)
return True, None
return False, None
def _get_api_client_for_base(self, base_url: str) -> Optional[HifiApiClient]:
base = base_url.rstrip("/")
for client in self.api_clients:
@@ -1180,7 +1418,7 @@ class HIFI(Provider):
urls.append(raw.strip())
cleaned = [u.rstrip("/") for u in urls if isinstance(u, str) and u.strip()]
if not cleaned:
cleaned = [DEFAULT_API_URLS[0]]
cleaned = [URL_API[0]]
return cleaned
def _build_search_params(self, query: str) -> Dict[str, str]:
@@ -1342,58 +1580,15 @@ class HIFI(Provider):
@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
return coerce_duration_seconds(value)
@staticmethod
def _stringify(value: Any) -> str:
text = str(value or "").strip()
return text
return stringify(value)
@staticmethod
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 = str(artist.get("name") or "").strip()
if name and name not in names:
names.append(name)
if not names:
primary = item.get("artist")
if isinstance(primary, dict):
name = str(primary.get("name") or "").strip()
if name:
names.append(name)
return names
return extract_artists(item)
def _item_to_result(self, item: Dict[str, Any]) -> Optional[SearchResult]:
if not isinstance(item, dict):
@@ -1619,52 +1814,7 @@ 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
return build_track_tags(metadata)
def selector(
self,

View File

@@ -543,9 +543,72 @@ def adjust_output_dir_for_alldebrid(
class AllDebrid(Provider):
# Magnet URIs should be routed through this provider.
TABLE_AUTO_STAGES = {"alldebrid": ["download-file"]}
URL = ("magnet:",)
URL_DOMAINS = ()
@staticmethod
def _resolve_magnet_spec_from_result(result: Any) -> Optional[str]:
table = getattr(result, "table", None)
media_kind = getattr(result, "media_kind", None)
tags = getattr(result, "tag", None)
full_metadata = getattr(result, "full_metadata", None)
target = getattr(result, "path", None) or getattr(result, "url", None)
if not table or str(table).strip().lower() != "alldebrid":
return None
kind_val = str(media_kind or "").strip().lower()
is_folder = kind_val == "folder"
if not is_folder and isinstance(tags, (list, set)):
for tag in tags:
if str(tag or "").strip().lower() == "folder":
is_folder = True
break
if not is_folder:
return resolve_magnet_spec(str(target or "")) if isinstance(target, str) else None
metadata = full_metadata if isinstance(full_metadata, dict) else {}
candidates: List[str] = []
def _maybe_add(value: Any) -> None:
if isinstance(value, str):
cleaned = value.strip()
if cleaned:
candidates.append(cleaned)
magnet_block = metadata.get("magnet")
if isinstance(magnet_block, dict):
for inner in ("magnet", "magnet_link", "link", "url"):
_maybe_add(magnet_block.get(inner))
for inner in ("hash", "info_hash", "torrenthash", "magnethash"):
_maybe_add(magnet_block.get(inner))
else:
_maybe_add(magnet_block)
for extra in ("magnet_link", "magnet_url", "magnet_spec"):
_maybe_add(metadata.get(extra))
_maybe_add(metadata.get("hash"))
_maybe_add(metadata.get("info_hash"))
for candidate in candidates:
spec = resolve_magnet_spec(candidate)
if spec:
return spec
return resolve_magnet_spec(str(target)) if isinstance(target, str) else None
def handle_url(self, url: str, *, output_dir: Optional[Path] = None) -> Tuple[bool, Optional[Path]]:
spec = resolve_magnet_spec(url)
if not spec:
return False, None
cfg = self.config if isinstance(self.config, dict) else {}
try:
prepare_magnet(spec, cfg)
return True, None
except Exception:
return False, None
@classmethod
def url_patterns(cls) -> Tuple[str, ...]:
# Combine static patterns with cached host domains.
@@ -744,11 +807,42 @@ class AllDebrid(Provider):
except Exception:
return None
def download_items(
self,
result: SearchResult,
output_dir: Path,
*,
emit: Callable[[Path, str, str, Dict[str, Any]], None],
progress: Any,
quiet_mode: bool,
path_from_result: Callable[[Any], Path],
config: Optional[Dict[str, Any]] = None,
) -> int:
spec = self._resolve_magnet_spec_from_result(result)
if not spec:
return 0
cfg = config if isinstance(config, dict) else (self.config or {})
def _on_emit(path: Path, file_url: str, relpath: str, metadata: Dict[str, Any]) -> None:
emit(path, file_url, relpath, metadata)
downloaded, _ = download_magnet(
spec,
str(getattr(result, "path", "") or ""),
output_dir,
cfg,
progress,
quiet_mode,
path_from_result,
_on_emit,
)
return downloaded
@staticmethod
def _flatten_files(items: Any,
*,
_prefix: Optional[List[str]] = None) -> Iterable[Dict[str,
Any]]:
_prefix: Optional[List[str]] = None) -> Iterable[Dict[str, Any]]:
"""Flatten AllDebrid magnet file tree into file dicts, preserving relative paths.
API commonly returns:
@@ -784,9 +878,7 @@ class AllDebrid(Provider):
name = node.get("n") or node.get("name")
link = node.get("l") or node.get("link")
if isinstance(name,
str) and name.strip() and isinstance(link,
str) and link.strip():
if isinstance(name, str) and name.strip() and isinstance(link, str) and link.strip():
rel_parts = prefix + [name.strip()]
relpath = "/".join([p for p in rel_parts if p])
enriched = dict(node)
@@ -932,6 +1024,19 @@ class AllDebrid(Provider):
except Exception:
size_bytes = None
metadata = {
"magnet": magnet_status,
"magnet_id": magnet_id,
"magnet_name": magnet_name,
"relpath": relpath,
"file": file_node,
"provider": "alldebrid",
"provider_view": "files",
}
if file_url:
metadata["_selection_args"] = ["-url", file_url]
metadata["_selection_action"] = ["download-file", "-url", file_url]
results.append(
SearchResult(
table="alldebrid",
@@ -952,15 +1057,7 @@ class AllDebrid(Provider):
("ID",
str(magnet_id)),
],
full_metadata={
"magnet": magnet_status,
"magnet_id": magnet_id,
"magnet_name": magnet_name,
"relpath": relpath,
"file": file_node,
"provider": "alldebrid",
"provider_view": "files",
},
full_metadata=metadata,
)
)
if len(results) >= max(1, limit):

View File

@@ -11,6 +11,15 @@ import subprocess
from API.HTTP import HTTPClient
from ProviderCore.base import SearchResult
try:
from Provider.HIFI import HIFI
except ImportError: # pragma: no cover - optional
HIFI = None
from Provider.tidal_shared import (
build_track_tags,
extract_artists,
stringify,
)
try: # Optional dependency for IMDb scraping
from imdbinfo.services import search_title # type: ignore
except ImportError: # pragma: no cover - optional
@@ -1416,6 +1425,95 @@ except Exception:
# Registry ---------------------------------------------------------------
class TidalMetadataProvider(MetadataProvider):
"""Metadata provider that reuses the HIFI 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")
super().__init__(config)
self._provider = HIFI(self.config)
def search(self, query: str, limit: int = 10) -> List[Dict[str, Any]]:
normalized = str(query or "").strip()
if not normalized:
return []
try:
results = self._provider.search(normalized, limit=limit)
except Exception as exc:
debug(f"[tidal-meta] search failed for '{normalized}': {exc}")
return []
items: List[Dict[str, Any]] = []
for result in results:
metadata = getattr(result, "full_metadata", {}) or {}
if not isinstance(metadata, dict):
metadata = {}
title = stringify(metadata.get("title") or result.title)
if not title:
continue
artists = extract_artists(metadata)
artist_display = ", ".join(artists) if artists else stringify(metadata.get("artist"))
album_obj = metadata.get("album")
album = ""
if isinstance(album_obj, dict):
album = stringify(album_obj.get("title"))
else:
album = stringify(metadata.get("album"))
year = stringify(metadata.get("releaseDate") or metadata.get("year") or metadata.get("date"))
track_id = self._provider._parse_track_id(metadata.get("trackId") or metadata.get("id"))
lyrics_data = None
if track_id is not None:
try:
lyrics_data = self._provider._fetch_track_lyrics(track_id)
except Exception as exc:
debug(f"[tidal-meta] lyrics lookup failed for {track_id}: {exc}")
lyrics = None
if isinstance(lyrics_data, dict):
lyrics = stringify(lyrics_data.get("lyrics") or lyrics_data.get("text"))
subtitles = stringify(lyrics_data.get("subtitles"))
if subtitles:
metadata.setdefault("_tidal_lyrics", {})["subtitles"] = subtitles
tags = sorted(build_track_tags(metadata))
items.append({
"title": title,
"artist": artist_display,
"album": album,
"year": year,
"lyrics": lyrics,
"tags": tags,
"provider": self.name,
"path": getattr(result, "path", ""),
"track_id": track_id,
"full_metadata": metadata,
})
return items
def to_tags(self, item: Dict[str, Any]) -> List[str]:
tags: List[str] = []
for value in item.get("tags", []):
value_text = stringify(value)
if value_text:
normalized = value_text.lower()
if normalized in {"tidal", "lossless"}:
continue
if normalized.startswith("quality:lossless"):
continue
tags.append(value_text)
return tags
_METADATA_PROVIDERS: Dict[str,
Type[MetadataProvider]] = {
"itunes": ITunesProvider,
@@ -1426,6 +1524,7 @@ _METADATA_PROVIDERS: Dict[str,
"musicbrainz": MusicBrainzMetadataProvider,
"imdb": ImdbMetadataProvider,
"ytdlp": YtdlpMetadataProvider,
"tidal": TidalMetadataProvider,
}

109
Provider/tidal_shared.py Normal file
View File

@@ -0,0 +1,109 @@
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