This commit is contained in:
2026-01-11 14:46:41 -08:00
parent 1f3de7db1c
commit 275f18cb31
19 changed files with 2741 additions and 394 deletions

284
API/Tidal.py Normal file
View File

@@ -0,0 +1,284 @@
from __future__ import annotations
from typing import Any, Dict, List, Optional, Set
from .base import API, ApiError
DEFAULT_BASE_URL = "https://tidal-api.binimum.org"
def stringify(value: Any) -> str:
"""Helper to ensure we have a stripped string or empty."""
return str(value or "").strip()
def extract_artists(item: Dict[str, Any]) -> List[str]:
"""Extract list of artist names from a Tidal-style metadata dict."""
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]:
"""Create a set of searchable tags from track metadata."""
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 parse_track_item(item: Dict[str, Any]) -> Dict[str, Any]:
"""Parse raw Tidal track data into a clean, flat dictionary.
Extracts core fields: id, title, duration, Track:, url, artist name, and album title.
"""
if not isinstance(item, dict):
return {}
# Handle the "data" wrapper if present
data = item.get("data") if isinstance(item.get("data"), dict) else item
artist_name = ""
artist_obj = data.get("artist")
if isinstance(artist_obj, dict):
artist_name = stringify(artist_obj.get("name"))
if not artist_name:
artists = extract_artists(data)
if artists:
artist_name = artists[0]
album_title = ""
album_obj = data.get("album")
if isinstance(album_obj, dict):
album_title = stringify(album_obj.get("title"))
if not album_title and isinstance(data.get("album"), str):
album_title = stringify(data.get("album"))
return {
"id": data.get("id"),
"title": stringify(data.get("title")),
"duration": data.get("duration"),
"Track:": data.get("trackNumber"),
"url": stringify(data.get("url")),
"artist": artist_name,
"album": album_title,
}
def coerce_duration_seconds(value: Any) -> Optional[int]:
"""Attempt to extracts seconds from various Tidal duration formats."""
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:
# Suspect milliseconds
num = num / 1000.0
return int(round(num))
except Exception:
continue
return None
class TidalApiError(ApiError):
"""Raised when the Tidal API returns an error or malformed response."""
class Tidal(API):
"""Client for the Tidal (Tidal) API endpoints.
This client communicates with the configured Tidal backend to retrieve
track metadata, manifests, search results, and lyrics.
"""
def __init__(self, base_url: str = DEFAULT_BASE_URL, *, timeout: float = 10.0) -> None:
super().__init__(base_url, timeout)
def search(self, params: Dict[str, str]) -> Dict[str, Any]:
usable = {k: v for k, v in (params or {}).items() if v}
search_keys = [key for key in ("s", "a", "v", "p") if usable.get(key)]
if not search_keys:
raise TidalApiError("One of s/a/v/p is required for /search/")
if len(search_keys) > 1:
first = search_keys[0]
usable = {first: usable[first]}
return self._get_json("search/", params=usable)
def track(self, track_id: int, *, quality: Optional[str] = None) -> Dict[str, Any]:
try:
track_int = int(track_id)
except Exception as exc:
raise TidalApiError(f"track_id must be int-compatible: {exc}") from exc
if track_int <= 0:
raise TidalApiError("track_id must be positive")
p: Dict[str, Any] = {"id": track_int}
if quality:
p["quality"] = str(quality)
return self._get_json("track/", params=p)
def info(self, track_id: int) -> Dict[str, Any]:
"""Fetch and parse core track metadata (id, title, artist, album, duration, etc)."""
try:
track_int = int(track_id)
except Exception as exc:
raise TidalApiError(f"track_id must be int-compatible: {exc}") from exc
if track_int <= 0:
raise TidalApiError("track_id must be positive")
raw = self._get_json("info/", params={"id": track_int})
return parse_track_item(raw)
def album(self, album_id: int) -> Dict[str, Any]:
"""Fetch album details, including track list when provided by the backend."""
try:
album_int = int(album_id)
except Exception as exc:
raise TidalApiError(f"album_id must be int-compatible: {exc}") from exc
if album_int <= 0:
raise TidalApiError("album_id must be positive")
return self._get_json("album/", params={"id": album_int})
def lyrics(self, track_id: int) -> Dict[str, Any]:
"""Fetch lyrics (including subtitles/LRC) for a track."""
try:
track_int = int(track_id)
except Exception as exc:
raise TidalApiError(f"track_id must be int-compatible: {exc}") from exc
if track_int <= 0:
raise TidalApiError("track_id must be positive")
return self._get_json("lyrics/", params={"id": track_int})
def get_full_track_metadata(self, track_id: int) -> Dict[str, Any]:
"""
Orchestrate fetching all details for a track:
1. Base info (/info/)
2. Playback/Quality info (/track/)
3. Lyrics (/lyrics/)
4. Derived tags
"""
try:
track_int = int(track_id)
except Exception as exc:
raise TidalApiError(f"track_id must be int-compatible: {exc}") from exc
# 1. Fetch info (metadata) - fetch raw to ensure all fields are available for merging
info_resp = self._get_json("info/", params={"id": track_int})
info_data = info_resp.get("data") if isinstance(info_resp, dict) else info_resp
if not isinstance(info_data, dict) or "id" not in info_data:
info_data = info_resp if isinstance(info_resp, dict) and "id" in info_resp else {}
# 2. Fetch track (manifest/bit depth)
track_resp = self.track(track_id)
# Note: track() method in this class currently returns raw JSON, so we handle it similarly.
track_data = track_resp.get("data") if isinstance(track_resp, dict) else track_resp
if not isinstance(track_data, dict) or "id" not in track_data:
track_data = track_resp if isinstance(track_resp, dict) and "id" in track_resp else {}
# 3. Fetch lyrics
lyrics_data = {}
try:
lyr_resp = self.lyrics(track_id)
lyrics_data = lyr_resp.get("lyrics") or lyr_resp if isinstance(lyr_resp, dict) else {}
except Exception:
pass
# Merged data for tags and parsing
merged_md = {}
if isinstance(info_data, dict):
merged_md.update(info_data)
if isinstance(track_data, dict):
merged_md.update(track_data)
# Derived tags and normalized/parsed info
tags = build_track_tags(merged_md)
parsed_info = parse_track_item(merged_md)
# Structure for return
return {
"metadata": merged_md,
"parsed": parsed_info,
"tags": list(tags),
"lyrics": lyrics_data,
}
# Legacy alias for TidalApiClient
TidalApiClient = Tidal

50
API/base.py Normal file
View File

@@ -0,0 +1,50 @@
from __future__ import annotations
import json
from typing import Any, Dict, Optional
from .HTTP import HTTPClient
class ApiError(Exception):
"""Base exception for API errors."""
pass
class API:
"""Base class for API clients using the internal HTTPClient."""
def __init__(self, base_url: str, timeout: float = 10.0) -> None:
self.base_url = str(base_url or "").rstrip("/")
self.timeout = float(timeout)
def _get_json(
self,
path: str,
params: Optional[Dict[str, Any]] = None,
headers: Optional[Dict[str, str]] = None,
) -> Dict[str, Any]:
url = f"{self.base_url}/{str(path or '').lstrip('/')}"
try:
with HTTPClient(timeout=self.timeout, headers=headers) as client:
response = client.get(url, params=params, allow_redirects=True)
response.raise_for_status()
return response.json()
except Exception as exc:
raise ApiError(f"API request failed for {url}: {exc}") from exc
def _post_json(
self,
path: str,
json_data: Optional[Dict[str, Any]] = None,
params: Optional[Dict[str, Any]] = None,
headers: Optional[Dict[str, str]] = None,
) -> Dict[str, Any]:
url = f"{self.base_url}/{str(path or '').lstrip('/')}"
try:
with HTTPClient(timeout=self.timeout, headers=headers) as client:
response = client.post(url, json=json_data, params=params, allow_redirects=True)
response.raise_for_status()
return response.json()
except Exception as exc:
raise ApiError(f"API request failed for {url}: {exc}") from exc

View File

@@ -1,94 +0,0 @@
from __future__ import annotations
from typing import Any, Dict, Optional
from .HTTP import HTTPClient
DEFAULT_BASE_URL = "https://tidal-api.binimum.org"
class HifiApiError(Exception):
"""Raised when the HiFi API returns an error or malformed response."""
class HifiApiClient:
"""Lightweight client for the hifi-api endpoints.
Supported endpoints:
- GET /search/ with exactly one of s, a, v, p
- GET /track/ with id (and optional quality)
- GET /info/ with id
- GET /album/ with id
- GET /lyrics/ with id
"""
def __init__(self, base_url: str = DEFAULT_BASE_URL, *, timeout: float = 10.0) -> None:
self.base_url = str(base_url or DEFAULT_BASE_URL).rstrip("/")
self.timeout = float(timeout)
def _get_json(self, path: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
url = f"{self.base_url}/{str(path or '').lstrip('/')}"
with HTTPClient(timeout=self.timeout) as client:
response = client.get(url, params=params, allow_redirects=True)
response.raise_for_status()
try:
return response.json()
except Exception as exc: # pragma: no cover - defensive
raise HifiApiError(f"Invalid JSON response from {url}: {exc}") from exc
def search(self, params: Dict[str, str]) -> Dict[str, Any]:
usable = {k: v for k, v in (params or {}).items() if v}
search_keys = [key for key in ("s", "a", "v", "p") if usable.get(key)]
if not search_keys:
raise HifiApiError("One of s/a/v/p is required for /search/")
if len(search_keys) > 1:
first = search_keys[0]
usable = {first: usable[first]}
return self._get_json("search/", params=usable)
def track(self, track_id: int, *, quality: Optional[str] = None) -> Dict[str, Any]:
try:
track_int = int(track_id)
except Exception as exc:
raise HifiApiError(f"track_id must be int-compatible: {exc}") from exc
if track_int <= 0:
raise HifiApiError("track_id must be positive")
params: Dict[str, Any] = {"id": track_int}
if quality:
params["quality"] = str(quality)
return self._get_json("track/", params=params)
def info(self, track_id: int) -> Dict[str, Any]:
try:
track_int = int(track_id)
except Exception as exc:
raise HifiApiError(f"track_id must be int-compatible: {exc}") from exc
if track_int <= 0:
raise HifiApiError("track_id must be positive")
return self._get_json("info/", params={"id": track_int})
def album(self, album_id: int) -> Dict[str, Any]:
"""Fetch album details, including track list when provided by the backend."""
try:
album_int = int(album_id)
except Exception as exc:
raise HifiApiError(f"album_id must be int-compatible: {exc}") from exc
if album_int <= 0:
raise HifiApiError("album_id must be positive")
return self._get_json("album/", params={"id": album_int})
def lyrics(self, track_id: int) -> Dict[str, Any]:
"""Fetch lyrics (including subtitles/LRC) for a track."""
try:
track_int = int(track_id)
except Exception as exc:
raise HifiApiError(f"track_id must be int-compatible: {exc}") from exc
if track_int <= 0:
raise HifiApiError("track_id must be positive")
return self._get_json("lyrics/", params={"id": track_int})

View File

@@ -15,31 +15,18 @@ from __future__ import annotations
import json
from typing import Any, Dict, Optional
from API.HTTP import HTTPClient
from .base import API, ApiError
class LOCError(Exception):
class LOCError(ApiError):
pass
class LOCClient:
class LOCClient(API):
"""Minimal client for the public LoC JSON API."""
BASE_URL = "https://www.loc.gov"
def __init__(self, *, timeout: float = 20.0):
self.timeout = float(timeout)
def _get_json(self, path: str, params: Dict[str, Any]) -> Dict[str, Any]:
url = self.BASE_URL.rstrip("/") + "/" + str(path or "").lstrip("/")
try:
with HTTPClient(timeout=self.timeout) as client:
resp = client.get(url, params=params)
resp.raise_for_status()
# httpx.Response.json() exists but keep decoding consistent
return json.loads(resp.content.decode("utf-8"))
except Exception as exc:
raise LOCError(str(exc)) from exc
def __init__(self, *, base_url: str = "https://www.loc.gov", timeout: float = 20.0):
super().__init__(base_url=base_url, timeout=timeout)
def search_chronicling_america(
self,

View File

@@ -16,10 +16,10 @@ import json
import time
from typing import Any, Dict, List, Optional
from .HTTP import HTTPClient
from .base import API, ApiError
class PodcastIndexError(Exception):
class PodcastIndexError(ApiError):
pass
@@ -55,41 +55,31 @@ def build_auth_headers(
}
class PodcastIndexClient:
BASE_URL = "https://api.podcastindex.org/api/1.0"
class PodcastIndexClient(API):
def __init__(
self,
api_key: str,
api_secret: str,
*,
base_url: str = "https://api.podcastindex.org/api/1.0",
user_agent: str = "downlow/1.0",
timeout: float = 30.0,
):
super().__init__(base_url=base_url, timeout=timeout)
self.api_key = str(api_key or "").strip()
self.api_secret = str(api_secret or "").strip()
self.user_agent = str(user_agent or "downlow/1.0")
self.timeout = float(timeout)
if not self.api_key or not self.api_secret:
raise PodcastIndexError("PodcastIndex api key/secret are required")
def _get(self, path: str, *, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
url = self.BASE_URL.rstrip("/") + "/" + str(path or "").lstrip("/")
headers = build_auth_headers(
self.api_key,
self.api_secret,
user_agent=self.user_agent,
)
with HTTPClient(timeout=self.timeout, headers=headers) as client:
response = client.get(url, params=params)
response.raise_for_status()
try:
return json.loads(response.content.decode("utf-8"))
except Exception as exc:
raise PodcastIndexError(f"Invalid JSON response: {exc}")
return self._get_json(path, params=params, headers=headers)
def search_byterm(self, query: str, *, max_results: int = 10) -> List[Dict[str, Any]]:
q = str(query or "").strip()

22
CLI.py
View File

@@ -2234,7 +2234,7 @@ class PipelineExecutor:
# Prefer an explicit provider hint from table metadata when available.
# This keeps @N selectors working even when row payloads don't carry a
# provider key (or when they carry a table-type like hifi.album).
# provider key (or when they carry a table-type like tidal.album).
try:
meta = (
current_table.get_table_metadata()
@@ -2264,7 +2264,7 @@ class PipelineExecutor:
get_provider = None # type: ignore
is_known_provider_name = None # type: ignore
# If we have a table-type like "hifi.album", also try its provider prefix ("hifi")
# If we have a table-type like "tidal.album", also try its provider prefix ("tidal")
# when that prefix is a registered provider name.
if is_known_provider_name is not None:
try:
@@ -2498,7 +2498,7 @@ class PipelineExecutor:
# Selection should operate on the *currently displayed* selectable table.
# Some navigation flows (e.g. @.. back) can show a display table without
# updating current_stage_table. Provider selectors rely on current_stage_table
# to detect table type (e.g. hifi.album -> tracks), so sync it here.
# to detect table type (e.g. tidal.album -> tracks), so sync it here.
display_table = None
try:
display_table = (
@@ -2722,7 +2722,7 @@ class PipelineExecutor:
return False, None
# Provider selection expansion (non-terminal): allow certain provider tables
# (e.g. hifi.album) to expand to multiple downstream items when the user
# (e.g. tidal.album) to expand to multiple downstream items when the user
# pipes into another stage (e.g. @N | .mpv or @N | add-file).
table_type_hint = None
try:
@@ -2734,11 +2734,11 @@ class PipelineExecutor:
except Exception:
table_type_hint = None
if stages and isinstance(table_type_hint, str) and table_type_hint.strip().lower() == "hifi.album":
if stages and isinstance(table_type_hint, str) and table_type_hint.strip().lower() == "tidal.album":
try:
from ProviderCore.registry import get_provider
prov = get_provider("hifi", config)
prov = get_provider("tidal", config)
except Exception:
prov = None
@@ -2780,7 +2780,7 @@ class PipelineExecutor:
if track_items:
filtered = track_items
table_type_hint = "hifi.track"
table_type_hint = "tidal.track"
if PipelineExecutor._maybe_run_class_selector(
ctx,
@@ -2891,7 +2891,7 @@ class PipelineExecutor:
# (e.g., @1 | add-file ...), we want to attach the row selection
# args *to the auto-inserted stage* so the download command receives
# the selected row information immediately.
stages.append(list(auto_stage) + (source_args or []))
stages.append(list(auto_stage))
debug(f"Inserted auto stage before row action: {stages[-1]}")
# If the caller included a selection (e.g., @1) try to attach
@@ -2940,7 +2940,9 @@ class PipelineExecutor:
if first_cmd_norm not in (auto_cmd_norm, ".pipe", ".mpv"):
debug(f"Auto-inserting {auto_cmd_norm} after selection")
# Insert the auto stage before the user-specified stage
stages.insert(0, list(auto_stage) + (source_args or []))
# Note: Do NOT append source_args here - they are search tokens from
# the previous stage and should not be passed to the downloader.
stages.insert(0, list(auto_stage))
debug(f"Inserted auto stage before existing pipeline: {stages[0]}")
# If a selection is present, attach the row selection args to the
@@ -3278,7 +3280,7 @@ class PipelineExecutor:
stage_table = ctx.get_current_stage_table()
# Selection should operate on the table the user sees.
# If a display overlay table exists, force it as the current-stage table
# so provider selectors (e.g. hifi.album -> tracks) behave consistently.
# so provider selectors (e.g. tidal.album -> tracks) behave consistently.
try:
if display_table is not None and hasattr(ctx, "set_current_stage_table"):
ctx.set_current_stage_table(display_table)

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -131,7 +131,7 @@ class Provider(ABC):
#
# Example:
# TABLE_AUTO_STAGES = {"youtube": ["download-file"]}
# TABLE_AUTO_PREFIXES = {"hifi": ["download-file"]} # matches hifi.*
# TABLE_AUTO_PREFIXES = {"tidal": ["download-file"]} # matches tidal.*
TABLE_AUTO_STAGES: Dict[str, Sequence[str]] = {}
TABLE_AUTO_PREFIXES: Dict[str, Sequence[str]] = {}
AUTO_STAGE_USE_SELECTION_ARGS: bool = False

View File

@@ -69,11 +69,11 @@ class ProviderRegistry:
if override_name:
_add(override_name)
else:
# Use class name as the primary canonical name
_add(getattr(provider_class, "__name__", None))
_add(getattr(provider_class, "PROVIDER_NAME", None))
_add(getattr(provider_class, "NAME", None))
_add(getattr(provider_class, "__name__", None))
for alias in getattr(provider_class, "PROVIDER_ALIASES", ()) or ():
_add(alias)
@@ -193,9 +193,23 @@ class ProviderRegistry:
def has_name(self, name: str) -> bool:
return self.get(name) is not None
def _sync_subclasses(self) -> None:
"""Walk all Provider subclasses in memory and register them."""
def _walk(cls: Type[Provider]) -> None:
for sub in cls.__subclasses__():
if sub in {SearchProvider, FileProvider}:
_walk(sub)
continue
try:
self.register(sub)
except Exception:
pass
_walk(sub)
_walk(Provider)
REGISTRY = ProviderRegistry("Provider")
REGISTRY.discover()
REGISTRY._sync_subclasses()
def register_provider(
@@ -382,7 +396,7 @@ def match_provider_name_for_url(url: str) -> Optional[str]:
dom = dom_raw.lower()
if not dom:
continue
if dom.startswith("magnet:") or dom.startswith("http://") or dom.startswith("https://"):
if "://" in dom or dom.startswith("magnet:"):
if raw_url_lower.startswith(dom):
return info.canonical_name
continue

View File

@@ -2499,7 +2499,7 @@ def resolve_tidal_manifest_path(item: Any) -> Optional[str]:
raw_manifest = metadata.get("manifest")
if not raw_manifest:
# When piping directly from the HIFI search table, we may only have a track id.
# When piping directly from the Tidal search table, we may only have a track id.
# Fetch track details from the proxy so downstream stages can decode the manifest.
try:
already = bool(metadata.get("_tidal_track_details_fetched"))
@@ -2518,7 +2518,7 @@ def resolve_tidal_manifest_path(item: Any) -> Optional[str]:
if candidate_path:
m = re.search(
r"hifi:(?://)?track[\\/](\d+)",
r"tidal:(?://)?track[\\/](\d+)",
str(candidate_path),
flags=re.IGNORECASE,
)
@@ -2626,7 +2626,7 @@ def resolve_tidal_manifest_path(item: Any) -> Optional[str]:
except Exception:
pass
log(
f"[hifi] JSON manifest for track {metadata.get('trackId') or metadata.get('id')} had no playable urls",
f"[tidal] JSON manifest for track {metadata.get('trackId') or metadata.get('id')} had no playable urls",
file=sys.stderr,
)
except Exception as exc:
@@ -2637,7 +2637,7 @@ def resolve_tidal_manifest_path(item: Any) -> Optional[str]:
except Exception:
pass
log(
f"[hifi] Failed to parse JSON manifest for track {metadata.get('trackId') or metadata.get('id')}: {exc}",
f"[tidal] Failed to parse JSON manifest for track {metadata.get('trackId') or metadata.get('id')}: {exc}",
file=sys.stderr,
)
return None
@@ -2658,7 +2658,7 @@ def resolve_tidal_manifest_path(item: Any) -> Optional[str]:
pass
try:
log(
f"[hifi] Decoded manifest is not an MPD XML for track {metadata.get('trackId') or metadata.get('id')} (mime {manifest_mime or 'unknown'})",
f"[tidal] Decoded manifest is not an MPD XML for track {metadata.get('trackId') or metadata.get('id')} (mime {manifest_mime or 'unknown'})",
file=sys.stderr,
)
except Exception:
@@ -2681,13 +2681,13 @@ def resolve_tidal_manifest_path(item: Any) -> Optional[str]:
# Persist as .mpd for DASH manifests.
ext = "mpd"
manifest_dir = Path(tempfile.gettempdir()) / "medeia" / "hifi"
manifest_dir = Path(tempfile.gettempdir()) / "medeia" / "tidal"
try:
manifest_dir.mkdir(parents=True, exist_ok=True)
except Exception:
pass
filename = f"hifi-{track_safe}-{identifier_safe[:24]}.{ext}"
filename = f"tidal-{track_safe}-{identifier_safe[:24]}.{ext}"
target_path = manifest_dir / filename
try:
with open(target_path, "wb") as fh:

View File

@@ -1050,7 +1050,7 @@ class Add_File(Cmdlet):
"https://",
"magnet:",
"torrent:",
"hifi:",
"tidal:",
"hydrus:")):
log(
"add-file ingests local files only. Use download-file first.",
@@ -1067,7 +1067,7 @@ class Add_File(Cmdlet):
"https://",
"magnet:",
"torrent:",
"hifi:",
"tidal:",
"hydrus:")):
log(
"add-file ingests local files only. Use download-file first.",
@@ -1088,7 +1088,7 @@ class Add_File(Cmdlet):
"https://",
"magnet:",
"torrent:",
"hifi:",
"tidal:",
"hydrus:")):
log(
"add-file ingests local files only. Use download-file first.",
@@ -1214,7 +1214,7 @@ class Add_File(Cmdlet):
"https://",
"magnet:",
"torrent:",
"hifi:",
"tidal:",
"hydrus:")):
log("add-file ingests local files only.", file=sys.stderr)
return False

View File

@@ -931,7 +931,8 @@ class Download_File(Cmdlet):
pass
transfer_label = label
if str(table or "").lower() == "hifi":
table_type = str(table or "").lower()
if table_type == "tidal" or table_type.startswith("tidal."):
try:
progress.begin_transfer(label=transfer_label, total=None)
except Exception:
@@ -943,7 +944,9 @@ class Download_File(Cmdlet):
provider_sr = None
provider_obj = None
if table and get_search_provider and SearchResult:
provider_obj = get_search_provider(str(table), config)
# Strip sub-table suffix (e.g. tidal.track -> tidal) to find the provider key
provider_key = str(table).split(".")[0]
provider_obj = get_search_provider(provider_key, config)
if provider_obj is not None:
attempted_provider_download = True
sr = SearchResult(
@@ -1160,14 +1163,7 @@ class Download_File(Cmdlet):
pass
# Allow providers to add/enrich tags and metadata during download.
if str(table or "").lower() == "libgen" and provider_sr is not None:
try:
sr_tags = getattr(provider_sr, "tag", None)
if tags_list is None and isinstance(sr_tags, set) and sr_tags:
tags_list = sorted([str(t) for t in sr_tags if t])
except Exception:
pass
if provider_sr is not None:
try:
sr_md = getattr(provider_sr, "full_metadata", None)
if isinstance(sr_md, dict) and sr_md:
@@ -1183,6 +1179,15 @@ class Download_File(Cmdlet):
except Exception:
pass
# Prefer tags from the search result object if the provider mutated them during download.
try:
sr_tags = getattr(provider_sr, "tag", None)
if isinstance(sr_tags, (set, list)) and sr_tags:
# Re-sync tags_list with the potentially enriched provider_sr.tag
tags_list = sorted([str(t) for t in sr_tags if t])
except Exception:
pass
self._emit_local_file(
downloaded_path=downloaded_path,
source=str(target) if target else None,
@@ -1201,7 +1206,8 @@ class Download_File(Cmdlet):
except Exception as e:
log(f"Error downloading item: {e}", file=sys.stderr)
finally:
if str(table or "").lower() == "hifi":
table_type = str(table or "").lower()
if table_type == "tidal" or table_type.startswith("tidal."):
try:
progress.finish_transfer(label=transfer_label)
except Exception:

View File

@@ -66,8 +66,8 @@ class search_file(Cmdlet):
CmdletArg(
"provider",
type="string",
description=
"External provider name: bandcamp, libgen, soulseek, youtube, alldebrid, loc, internetarchive, hifi",
description="External provider name (e.g., tidal, youtube, soulseek, etc)",
choices=["bandcamp", "libgen", "soulseek", "youtube", "alldebrid", "loc", "internetarchive", "tidal", "tidal"],
),
CmdletArg(
"open",
@@ -116,7 +116,7 @@ class search_file(Cmdlet):
return ext[:5]
@staticmethod
def _get_hifi_view_from_query(query: str) -> str:
def _get_tidal_view_from_query(query: str) -> str:
text = str(query or "").strip()
if not text:
return "track"
@@ -303,10 +303,10 @@ class search_file(Cmdlet):
preserve_order = provider_lower in {"youtube", "openlibrary", "loc", "torrent"}
table_type = provider_name
table_meta: Dict[str, Any] = {"provider": provider_name}
if provider_lower == "hifi":
view = self._get_hifi_view_from_query(query)
if provider_lower == "tidal":
view = self._get_tidal_view_from_query(query)
table_meta["view"] = view
table_type = f"hifi.{view}"
table_type = f"tidal.{view}"
elif provider_lower == "internetarchive":
# Internet Archive search results are effectively folders (items); selecting @N
# should open a list of downloadable files for the chosen item.
@@ -339,10 +339,10 @@ class search_file(Cmdlet):
results = provider.search(query, limit=limit, filters=search_filters or None)
debug(f"[search-file] {provider_name} -> {len(results or [])} result(s)")
# HIFI artist UX: if there is exactly one artist match, auto-expand
# Tidal artist UX: if there is exactly one artist match, auto-expand
# directly to albums without requiring an explicit @1 selection.
if (
provider_lower == "hifi"
provider_lower == "tidal"
and table_meta.get("view") == "artist"
and isinstance(results, list)
and len(results) == 1
@@ -372,7 +372,7 @@ class search_file(Cmdlet):
if album_results:
results = album_results
table_type = "hifi.album"
table_type = "tidal.album"
table.set_table(table_type)
table_meta["view"] = "album"
try:

View File

@@ -790,9 +790,9 @@ def _get_playable_path(
if manifest_path:
path = manifest_path
else:
# If this is a hifi:// placeholder and we couldn't resolve a manifest, do not fall back.
# If this is a tidal:// placeholder and we couldn't resolve a manifest, do not fall back.
try:
if isinstance(path, str) and path.strip().lower().startswith("hifi:"):
if isinstance(path, str) and path.strip().lower().startswith("tidal:"):
try:
meta = None
if isinstance(item, dict):
@@ -803,7 +803,7 @@ def _get_playable_path(
print(str(meta.get("_tidal_manifest_error")), file=sys.stderr)
except Exception:
pass
print("HIFI selection has no playable DASH MPD manifest.", file=sys.stderr)
print("Tidal selection has no playable DASH MPD manifest.", file=sys.stderr)
return None
except Exception:
pass

134
search_results.txt Normal file
View File

@@ -0,0 +1,134 @@
DEBUG: Calling hifi.search(filters={'artist': 'bonobo'})
DEBUG:
┌─────────────────────────────── HTTP request ────────────────────────────────┐
│ method GET │
│ url https://triton.squid.wtf/search/ │
│ attempt 1/3 │
│ params {'s': '*'} │
│ headers {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) │
│ AppleWebKit/537.36'} │
│ verify C:\Users\Admin\AppData\Local\Programs\Python\Python313\Li… │
│ follow_redirects True │
└─────────────────────────────────────────────────────────────────────────────┘
DEBUG:
┌───────────────── HTTP response ─────────────────┐
│ method GET │
│ url https://triton.squid.wtf/search/ │
│ status 200 │
│ elapsed 0:00:00.592893 │
│ content_length None │
└─────────────────────────────────────────────────┘
DEBUG: hifi -> 25 result(s)
┌────────────────────────────────── Hifi: * ──────────────────────────────────┐
│ │
│ # TITLE DISC # TRACK # ALBUM ARTIST DURATI… QUALITY │
│ ───────────────────────────────────────────────────────────────────────── │
│ 1 (god 1 5 'n sync *nsync 4:43 lossle… │
│ must │
│ have │
│ spent) │
│ a │
│ little │
│ more │
│ time on │
│ you │
│ 2 (god 1 4 the *nsync 4:01 lossle… │
│ must essent… │
│ have *nsync │
│ spent) │
│ a │
│ little │
│ more │
│ time on │
│ you │
│ 3 ***** 1 15 the eminem, 4:51 lossle… │
│ please marsha… dr. │
│ ii mathers dre, │
│ lp snoop │
│ dogg, │
│ nate │
│ dogg, │
│ xzibit │
│ 4 *****s… 1 1 shut up jessie 2:23 lossle… │
│ up***** reyez, │
│ big │
│ sean │
│ 5 ***fla… 1 11 beyoncé beyonc… 4:11 lossle… │
│ (feat. chimam… │
│ chimam… ngozi │
│ ngozi adichie │
│ adichi… │
│ 6 **jean… 1 1 jeans jessie 3:15 lossle… │
│ reyez, │
│ miguel │
│ 7 *equip 1 2 you'll hot 2:46 lossle… │
│ sungla… be fine mullig… │
│ 8 better 1 1 better *nsync, 3:37 lossle… │
│ place place justin │
│ (from (from timber… │
│ trolls trolls │
│ band band │
│ togeth… togeth… │
│ 9 bring 1 8 blaque blaque, 3:38 lossle… │
│ it all *nsync │
│ to me │
│ (feat. │
│ *nsync) │
│ 10 bye bye 1 9 the *nsync 3:20 lossle… │
│ bye essent… │
│ *nsync │
│ 11 girlfr… 1 4 celebr… *nsync 4:14 lossle… │
│ 12 girlfr… 1 16 the *nsync, 4:45 lossle… │
│ (feat. essent… nelly │
│ nelly) *nsync │
│ 13 gone 1 6 celebr… *nsync 4:52 lossle… │
│ 14 here we 1 3 'n sync *nsync 3:36 lossle… │
│ go │
│ 15 i want 1 2 the *nsync 3:20 lossle… │
│ you essent… │
│ back *nsync │
│ 16 it 1 5 no *nsync 3:26 lossle… │
│ makes strings │
│ me ill attach… │
│ 17 it's 1 2 no *nsync 3:12 lossle… │
│ gonna strings │
│ be me attach… │
│ 18 just 1 4 no *nsync 4:09 lossle… │
│ got strings │
│ paid attach… │
│ 19 merry 1 4 home *nsync 4:15 lossle… │
│ christ… for │
│ happy christ… │
│ holida… │
│ 20 no 1 7 no *nsync 3:49 lossle… │
│ strings strings │
│ attach… attach… │
│ 21 pop 1 1 celebr… *nsync 3:58 lossle… │
│ 22 space 1 3 no *nsync, 4:22 lossle… │
│ cowboy strings lisa │
│ (yippi… attach… "left │
│ (feat. eye" │
│ lisa lopes │
│ "left │
│ eye" │
│ lopes) │
│ 23 tearin' 1 3 the *nsync 3:29 lossle… │
│ up my essent… │
│ heart *nsync │
│ 24 thinki… 1 5 the *nsync 3:58 lossle… │
│ of you essent… │
│ (i *nsync │
│ drive │
│ myself │
│ crazy) │
│ 25 this i 1 6 no *nsync 4:45 lossle… │
│ promise strings │
│ you attach… │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Usage: CLI.py [OPTIONS] COMMAND [ARGS]...
Try 'CLI.py --help' for help.
┌─ Error ─────────────────────────────────────────────────────────────────────┐
│ No such command '@1'. │
└─────────────────────────────────────────────────────────────────────────────┘