f
This commit is contained in:
284
API/Tidal.py
Normal file
284
API/Tidal.py
Normal 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
50
API/base.py
Normal 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
|
||||||
94
API/hifi.py
94
API/hifi.py
@@ -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})
|
|
||||||
23
API/loc.py
23
API/loc.py
@@ -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,
|
||||||
|
|||||||
@@ -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
22
CLI.py
@@ -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)
|
||||||
|
|||||||
166
Provider/HIFI.py
166
Provider/HIFI.py
@@ -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
2119
Provider/Tidal.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
134
search_results.txt
Normal 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'. │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────────┘
|
||||||
Reference in New Issue
Block a user