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 import json
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
from API.HTTP import HTTPClient from .base import API, ApiError
class LOCError(Exception): class LOCError(ApiError):
pass pass
class LOCClient: class LOCClient(API):
"""Minimal client for the public LoC JSON API.""" """Minimal client for the public LoC JSON API."""
BASE_URL = "https://www.loc.gov" def __init__(self, *, base_url: str = "https://www.loc.gov", timeout: float = 20.0):
super().__init__(base_url=base_url, timeout=timeout)
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 search_chronicling_america( def search_chronicling_america(
self, self,

View File

@@ -16,10 +16,10 @@ import json
import time import time
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from .HTTP import HTTPClient from .base import API, ApiError
class PodcastIndexError(Exception): class PodcastIndexError(ApiError):
pass pass
@@ -55,41 +55,31 @@ def build_auth_headers(
} }
class PodcastIndexClient: class PodcastIndexClient(API):
BASE_URL = "https://api.podcastindex.org/api/1.0"
def __init__( def __init__(
self, self,
api_key: str, api_key: str,
api_secret: str, api_secret: str,
*, *,
base_url: str = "https://api.podcastindex.org/api/1.0",
user_agent: str = "downlow/1.0", user_agent: str = "downlow/1.0",
timeout: float = 30.0, timeout: float = 30.0,
): ):
super().__init__(base_url=base_url, timeout=timeout)
self.api_key = str(api_key or "").strip() self.api_key = str(api_key or "").strip()
self.api_secret = str(api_secret or "").strip() self.api_secret = str(api_secret or "").strip()
self.user_agent = str(user_agent or "downlow/1.0") self.user_agent = str(user_agent or "downlow/1.0")
self.timeout = float(timeout)
if not self.api_key or not self.api_secret: if not self.api_key or not self.api_secret:
raise PodcastIndexError("PodcastIndex api key/secret are required") raise PodcastIndexError("PodcastIndex api key/secret are required")
def _get(self, path: str, *, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: 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( headers = build_auth_headers(
self.api_key, self.api_key,
self.api_secret, self.api_secret,
user_agent=self.user_agent, user_agent=self.user_agent,
) )
return self._get_json(path, params=params, headers=headers)
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}")
def search_byterm(self, query: str, *, max_results: int = 10) -> List[Dict[str, Any]]: def search_byterm(self, query: str, *, max_results: int = 10) -> List[Dict[str, Any]]:
q = str(query or "").strip() 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. # Prefer an explicit provider hint from table metadata when available.
# This keeps @N selectors working even when row payloads don't carry a # 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: try:
meta = ( meta = (
current_table.get_table_metadata() current_table.get_table_metadata()
@@ -2264,7 +2264,7 @@ class PipelineExecutor:
get_provider = None # type: ignore get_provider = None # type: ignore
is_known_provider_name = 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. # when that prefix is a registered provider name.
if is_known_provider_name is not None: if is_known_provider_name is not None:
try: try:
@@ -2498,7 +2498,7 @@ class PipelineExecutor:
# Selection should operate on the *currently displayed* selectable table. # Selection should operate on the *currently displayed* selectable table.
# Some navigation flows (e.g. @.. back) can show a display table without # Some navigation flows (e.g. @.. back) can show a display table without
# updating current_stage_table. Provider selectors rely on current_stage_table # 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 display_table = None
try: try:
display_table = ( display_table = (
@@ -2722,7 +2722,7 @@ class PipelineExecutor:
return False, None return False, None
# Provider selection expansion (non-terminal): allow certain provider tables # 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). # pipes into another stage (e.g. @N | .mpv or @N | add-file).
table_type_hint = None table_type_hint = None
try: try:
@@ -2734,11 +2734,11 @@ class PipelineExecutor:
except Exception: except Exception:
table_type_hint = None 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: try:
from ProviderCore.registry import get_provider from ProviderCore.registry import get_provider
prov = get_provider("hifi", config) prov = get_provider("tidal", config)
except Exception: except Exception:
prov = None prov = None
@@ -2780,7 +2780,7 @@ class PipelineExecutor:
if track_items: if track_items:
filtered = track_items filtered = track_items
table_type_hint = "hifi.track" table_type_hint = "tidal.track"
if PipelineExecutor._maybe_run_class_selector( if PipelineExecutor._maybe_run_class_selector(
ctx, ctx,
@@ -2891,7 +2891,7 @@ class PipelineExecutor:
# (e.g., @1 | add-file ...), we want to attach the row selection # (e.g., @1 | add-file ...), we want to attach the row selection
# args *to the auto-inserted stage* so the download command receives # args *to the auto-inserted stage* so the download command receives
# the selected row information immediately. # 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]}") debug(f"Inserted auto stage before row action: {stages[-1]}")
# If the caller included a selection (e.g., @1) try to attach # 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"): if first_cmd_norm not in (auto_cmd_norm, ".pipe", ".mpv"):
debug(f"Auto-inserting {auto_cmd_norm} after selection") debug(f"Auto-inserting {auto_cmd_norm} after selection")
# Insert the auto stage before the user-specified stage # 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]}") debug(f"Inserted auto stage before existing pipeline: {stages[0]}")
# If a selection is present, attach the row selection args to the # 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() stage_table = ctx.get_current_stage_table()
# Selection should operate on the table the user sees. # Selection should operate on the table the user sees.
# If a display overlay table exists, force it as the current-stage table # 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: try:
if display_table is not None and hasattr(ctx, "set_current_stage_table"): if display_table is not None and hasattr(ctx, "set_current_stage_table"):
ctx.set_current_stage_table(display_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 typing import Any, Dict, Iterable, List, Optional, Tuple
from urllib.parse import urlparse from urllib.parse import urlparse
from API.hifi import HifiApiClient from API.Tidal import (
from ProviderCore.base import Provider, SearchResult, parse_inline_query_arguments HifiApiClient,
from Provider.tidal_shared import (
build_track_tags, build_track_tags,
coerce_duration_seconds, coerce_duration_seconds,
extract_artists, extract_artists,
stringify, stringify,
) )
from ProviderCore.base import Provider, SearchResult, parse_inline_query_arguments
from SYS import pipeline as pipeline_context from SYS import pipeline as pipeline_context
from SYS.logger import debug, log from SYS.logger import debug, log
URL_API = ( URL_API = (
"https://tidal-api.binimum.org",
"https://triton.squid.wtf", "https://triton.squid.wtf",
"https://wolf.qqdl.site", "https://wolf.qqdl.site",
"https://maus.qqdl.site", "https://maus.qqdl.site",
@@ -33,6 +32,7 @@ URL_API = (
"https://hund.qqdl.site", "https://hund.qqdl.site",
"https://tidal.kinoplus.online", "https://tidal.kinoplus.online",
"https://tidal-api.binimum.org", "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}" return f"{mins}:{secs:02d}"
class HIFI(Provider): class Tidal(Provider):
TABLE_AUTO_STAGES = { TABLE_AUTO_STAGES = {
"hifi.track": ["download-file"], "hifi.track": ["download-file"],
@@ -237,6 +237,16 @@ class HIFI(Provider):
except Exception: except Exception:
return "", None 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] parts = [segment for segment in (parsed.path or "").split("/") if segment]
if not parts: if not parts:
return "", None return "", None
@@ -248,7 +258,7 @@ class HIFI(Provider):
return "", None return "", None
view = parts[idx].lower() view = parts[idx].lower()
if view not in {"album", "track"}: if view not in {"album", "track", "artist"}:
return "", None return "", None
for segment in parts[idx + 1:]: for segment in parts[idx + 1:]:
@@ -279,6 +289,7 @@ class HIFI(Provider):
annotations=["tidal", "track"], annotations=["tidal", "track"],
media_kind="audio", media_kind="audio",
full_metadata=dict(detail) if isinstance(detail, dict) else {}, 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]]: def _extract_artist_selection_context(self, selected_items: List[Any]) -> List[Tuple[int, str]]:
@@ -802,6 +813,11 @@ class HIFI(Provider):
full_metadata=md, full_metadata=md,
) )
@staticmethod
def url_patterns() -> List[str]:
"""Return URL prefixes handled by this provider."""
return ["hifi://", "tidal.com"]
@staticmethod @staticmethod
def _find_ffmpeg() -> Optional[str]: def _find_ffmpeg() -> Optional[str]:
exe = shutil.which("ffmpeg") exe = shutil.which("ffmpeg")
@@ -1113,34 +1129,28 @@ class HIFI(Provider):
if isinstance(getattr(result, "full_metadata", None), dict): if isinstance(getattr(result, "full_metadata", None), dict):
md = dict(getattr(result, "full_metadata") or {}) md = dict(getattr(result, "full_metadata") or {})
if not md.get("manifest"): track_id = self._extract_track_id_from_result(result)
track_id = self._extract_track_id_from_result(result) if track_id:
if track_id: # Multi-part enrichment from API: metadata, tags, and lyrics.
detail = self._fetch_track_details(track_id) full_data = self._fetch_all_track_data(track_id)
if isinstance(detail, dict) and detail: if isinstance(full_data, dict):
try: # 1. Update metadata
md.update(detail) api_md = full_data.get("metadata")
except Exception: if isinstance(api_md, dict):
md = detail 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). # 3. Handle lyrics
try: lyrics = full_data.get("lyrics")
track_id_for_lyrics = self._extract_track_id_from_result(result) if isinstance(lyrics, dict) and lyrics:
except Exception: md.setdefault("lyrics", lyrics)
track_id_for_lyrics = None subtitles = lyrics.get("subtitles")
if track_id_for_lyrics and not md.get("_tidal_lyrics_subtitles"):
lyr = self._fetch_track_lyrics(track_id_for_lyrics)
if isinstance(lyr, dict) and lyr:
try:
md.setdefault("lyrics", lyr)
except Exception:
pass
try:
subtitles = lyr.get("subtitles")
if isinstance(subtitles, str) and subtitles.strip(): if isinstance(subtitles, str) and subtitles.strip():
md["_tidal_lyrics_subtitles"] = subtitles.strip() md["_tidal_lyrics_subtitles"] = subtitles.strip()
except Exception:
pass
# Ensure downstream cmdlets see our enriched metadata. # Ensure downstream cmdlets see our enriched metadata.
try: try:
@@ -1665,6 +1675,7 @@ class HIFI(Provider):
tag=tags, tag=tags,
columns=columns, columns=columns,
full_metadata=full_md, full_metadata=full_md,
selection_args=["-url", path],
) )
if url_value: if url_value:
try: try:
@@ -1739,66 +1750,34 @@ class HIFI(Provider):
return contexts return contexts
def _fetch_track_details(self, track_id: int) -> Optional[Dict[str, Any]]: def _fetch_track_details(self, track_id: int) -> Optional[Dict[str, Any]]:
if track_id <= 0: """Legacy wrapper returning just metadata from the consolidated API call."""
return None res = self._fetch_all_track_data(track_id)
return res.get("metadata") if res else 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
def _fetch_track_info(self, track_id: int) -> Optional[Dict[str, Any]]: 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: if track_id <= 0:
return None return None
for base in self.api_urls: for base in self.api_urls:
endpoint = f"{base.rstrip('/')}/info/"
try: try:
client = self._get_api_client_for_base(base) client = self._get_api_client_for_base(base)
payload = client.info(track_id) if client else None if not client:
data = payload.get("data") if isinstance(payload, dict) else None continue
if isinstance(data, dict): # This method in the API client handles merging info+track and building tags.
return data return client.get_full_track_metadata(track_id)
except Exception as exc: except Exception as exc:
debug(f"[hifi] Info lookup failed for {endpoint}: {exc}") debug(f"[hifi] Full track fetch failed for {base}: {exc}")
continue continue
return None return None
def _fetch_track_lyrics(self, track_id: int) -> Optional[Dict[str, Any]]: def _fetch_track_lyrics(self, track_id: int) -> Optional[Dict[str, Any]]:
if track_id <= 0: """Legacy wrapper returning just lyrics from the consolidated API call."""
return None res = self._fetch_all_track_data(track_id)
for base in self.api_urls: return res.get("lyrics") if res else None
endpoint = f"{base.rstrip('/')}/lyrics/"
try:
client = self._get_api_client_for_base(base)
payload = client.lyrics(track_id) if client else None
if not isinstance(payload, dict):
continue
lyrics_obj = payload.get("lyrics")
if isinstance(lyrics_obj, dict) and lyrics_obj:
return lyrics_obj
data_obj = payload.get("data")
if isinstance(data_obj, dict) and data_obj:
return data_obj
except Exception as exc:
debug(f"[hifi] Lyrics lookup failed for {endpoint}: {exc}")
continue
return None
def _build_track_columns(self, detail: Dict[str, Any], track_id: int) -> List[Tuple[str, str]]: def _build_track_columns(self, detail: Dict[str, Any], track_id: int) -> List[Tuple[str, str]]:
values: 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]: def _build_track_tags(self, metadata: Dict[str, Any]) -> set[str]:
return build_track_tags(metadata) return build_track_tags(metadata)
@staticmethod
def selection_auto_stage( def selection_auto_stage(
self,
table_type: str, table_type: str,
stage_args: Optional[Sequence[str]] = None, stage_args: Optional[Sequence[str]] = None,
) -> Optional[List[str]]: ) -> Optional[List[str]]:
@@ -2004,17 +1983,11 @@ class HIFI(Provider):
return True 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)): 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: return False
meta = (
current_table.get_table_metadata()
if current_table is not None and hasattr(current_table, "get_table_metadata")
else {}
)
except Exception:
meta = {}
if isinstance(meta, dict) and meta.get("resolved_manifest"):
return False
contexts = self._extract_track_selection_context(selected_items) contexts = self._extract_track_selection_context(selected_items)
try: try:
@@ -2047,17 +2020,7 @@ class HIFI(Provider):
pass pass
results_payload: List[Dict[str, Any]] = [] results_payload: List[Dict[str, Any]] = []
for track_id, title, path, detail in track_details: 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. resolved_path = f"hifi://track/{track_id}"
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}"
artists = self._extract_artists(detail) artists = self._extract_artists(detail)
artist_display = ", ".join(artists) if artists else "" artist_display = ", ".join(artists) if artists else ""
@@ -2086,6 +2049,7 @@ class HIFI(Provider):
columns=columns, columns=columns,
full_metadata=detail, full_metadata=detail,
tag=tags, tag=tags,
selection_args=["-url", resolved_path],
) )
if url_value: if url_value:
try: 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 API.HTTP import HTTPClient
from ProviderCore.base import SearchResult from ProviderCore.base import SearchResult
try: try:
from Provider.HIFI import HIFI from Provider.Tidal import Tidal
except ImportError: # pragma: no cover - optional except ImportError: # pragma: no cover - optional
HIFI = None Tidal = None
from Provider.tidal_shared import ( from API.Tidal import (
build_track_tags, build_track_tags,
extract_artists, extract_artists,
stringify, stringify,
@@ -1426,17 +1426,17 @@ except Exception:
# Registry --------------------------------------------------------------- # Registry ---------------------------------------------------------------
class TidalMetadataProvider(MetadataProvider): 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 @property
def name(self) -> str: # type: ignore[override] def name(self) -> str: # type: ignore[override]
return "tidal" return "tidal"
def __init__(self, config: Optional[Dict[str, Any]] = None) -> None: def __init__(self, config: Optional[Dict[str, Any]] = None) -> None:
if HIFI is None: if Tidal is None:
raise RuntimeError("HIFI provider unavailable for tidal metadata") raise RuntimeError("Tidal provider unavailable for tidal metadata")
super().__init__(config) 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]]: def search(self, query: str, limit: int = 10) -> List[Dict[str, Any]]:
normalized = str(query or "").strip() normalized = str(query or "").strip()

View File

@@ -236,7 +236,7 @@ class Soulseek(Provider):
if not stage_is_last: if not stage_is_last:
return False 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) # 1. Fetch more data (e.g. user shares)
# 2. Create a new ResultTable # 2. Create a new ResultTable
# 3. ctx.set_current_stage_table(new_table) # 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: # Example:
# TABLE_AUTO_STAGES = {"youtube": ["download-file"]} # 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_STAGES: Dict[str, Sequence[str]] = {}
TABLE_AUTO_PREFIXES: Dict[str, Sequence[str]] = {} TABLE_AUTO_PREFIXES: Dict[str, Sequence[str]] = {}
AUTO_STAGE_USE_SELECTION_ARGS: bool = False AUTO_STAGE_USE_SELECTION_ARGS: bool = False

View File

@@ -69,11 +69,11 @@ class ProviderRegistry:
if override_name: if override_name:
_add(override_name) _add(override_name)
else: 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, "PROVIDER_NAME", None))
_add(getattr(provider_class, "NAME", None)) _add(getattr(provider_class, "NAME", None))
_add(getattr(provider_class, "__name__", None))
for alias in getattr(provider_class, "PROVIDER_ALIASES", ()) or (): for alias in getattr(provider_class, "PROVIDER_ALIASES", ()) or ():
_add(alias) _add(alias)
@@ -193,9 +193,23 @@ class ProviderRegistry:
def has_name(self, name: str) -> bool: def has_name(self, name: str) -> bool:
return self.get(name) is not None 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 = ProviderRegistry("Provider")
REGISTRY.discover() REGISTRY.discover()
REGISTRY._sync_subclasses()
def register_provider( def register_provider(
@@ -382,7 +396,7 @@ def match_provider_name_for_url(url: str) -> Optional[str]:
dom = dom_raw.lower() dom = dom_raw.lower()
if not dom: if not dom:
continue 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): if raw_url_lower.startswith(dom):
return info.canonical_name return info.canonical_name
continue continue

View File

@@ -2499,7 +2499,7 @@ def resolve_tidal_manifest_path(item: Any) -> Optional[str]:
raw_manifest = metadata.get("manifest") raw_manifest = metadata.get("manifest")
if not raw_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. # Fetch track details from the proxy so downstream stages can decode the manifest.
try: try:
already = bool(metadata.get("_tidal_track_details_fetched")) already = bool(metadata.get("_tidal_track_details_fetched"))
@@ -2518,7 +2518,7 @@ def resolve_tidal_manifest_path(item: Any) -> Optional[str]:
if candidate_path: if candidate_path:
m = re.search( m = re.search(
r"hifi:(?://)?track[\\/](\d+)", r"tidal:(?://)?track[\\/](\d+)",
str(candidate_path), str(candidate_path),
flags=re.IGNORECASE, flags=re.IGNORECASE,
) )
@@ -2626,7 +2626,7 @@ def resolve_tidal_manifest_path(item: Any) -> Optional[str]:
except Exception: except Exception:
pass pass
log( 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, file=sys.stderr,
) )
except Exception as exc: except Exception as exc:
@@ -2637,7 +2637,7 @@ def resolve_tidal_manifest_path(item: Any) -> Optional[str]:
except Exception: except Exception:
pass pass
log( 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, file=sys.stderr,
) )
return None return None
@@ -2658,7 +2658,7 @@ def resolve_tidal_manifest_path(item: Any) -> Optional[str]:
pass pass
try: try:
log( 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, file=sys.stderr,
) )
except Exception: except Exception:
@@ -2681,13 +2681,13 @@ def resolve_tidal_manifest_path(item: Any) -> Optional[str]:
# Persist as .mpd for DASH manifests. # Persist as .mpd for DASH manifests.
ext = "mpd" ext = "mpd"
manifest_dir = Path(tempfile.gettempdir()) / "medeia" / "hifi" manifest_dir = Path(tempfile.gettempdir()) / "medeia" / "tidal"
try: try:
manifest_dir.mkdir(parents=True, exist_ok=True) manifest_dir.mkdir(parents=True, exist_ok=True)
except Exception: except Exception:
pass pass
filename = f"hifi-{track_safe}-{identifier_safe[:24]}.{ext}" filename = f"tidal-{track_safe}-{identifier_safe[:24]}.{ext}"
target_path = manifest_dir / filename target_path = manifest_dir / filename
try: try:
with open(target_path, "wb") as fh: with open(target_path, "wb") as fh:

View File

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

View File

@@ -931,7 +931,8 @@ class Download_File(Cmdlet):
pass pass
transfer_label = label 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: try:
progress.begin_transfer(label=transfer_label, total=None) progress.begin_transfer(label=transfer_label, total=None)
except Exception: except Exception:
@@ -943,7 +944,9 @@ class Download_File(Cmdlet):
provider_sr = None provider_sr = None
provider_obj = None provider_obj = None
if table and get_search_provider and SearchResult: 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: if provider_obj is not None:
attempted_provider_download = True attempted_provider_download = True
sr = SearchResult( sr = SearchResult(
@@ -1160,14 +1163,7 @@ class Download_File(Cmdlet):
pass pass
# Allow providers to add/enrich tags and metadata during download. # Allow providers to add/enrich tags and metadata during download.
if str(table or "").lower() == "libgen" and provider_sr is not None: if 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
try: try:
sr_md = getattr(provider_sr, "full_metadata", None) sr_md = getattr(provider_sr, "full_metadata", None)
if isinstance(sr_md, dict) and sr_md: if isinstance(sr_md, dict) and sr_md:
@@ -1183,6 +1179,15 @@ class Download_File(Cmdlet):
except Exception: except Exception:
pass 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( self._emit_local_file(
downloaded_path=downloaded_path, downloaded_path=downloaded_path,
source=str(target) if target else None, source=str(target) if target else None,
@@ -1201,7 +1206,8 @@ class Download_File(Cmdlet):
except Exception as e: except Exception as e:
log(f"Error downloading item: {e}", file=sys.stderr) log(f"Error downloading item: {e}", file=sys.stderr)
finally: finally:
if str(table or "").lower() == "hifi": table_type = str(table or "").lower()
if table_type == "tidal" or table_type.startswith("tidal."):
try: try:
progress.finish_transfer(label=transfer_label) progress.finish_transfer(label=transfer_label)
except Exception: except Exception:

View File

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

View File

@@ -790,9 +790,9 @@ def _get_playable_path(
if manifest_path: if manifest_path:
path = manifest_path path = manifest_path
else: 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: try:
if isinstance(path, str) and path.strip().lower().startswith("hifi:"): if isinstance(path, str) and path.strip().lower().startswith("tidal:"):
try: try:
meta = None meta = None
if isinstance(item, dict): if isinstance(item, dict):
@@ -803,7 +803,7 @@ def _get_playable_path(
print(str(meta.get("_tidal_manifest_error")), file=sys.stderr) print(str(meta.get("_tidal_manifest_error")), file=sys.stderr)
except Exception: except Exception:
pass 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 return None
except Exception: except Exception:
pass 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'. │
└─────────────────────────────────────────────────────────────────────────────┘