f
This commit is contained in:
@@ -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.Tidal import Tidal as tidalApiClient
|
|
||||||
from ProviderCore.base import Provider, SearchResult, parse_inline_query_arguments
|
|
||||||
from API.Tidal import (
|
from API.Tidal import (
|
||||||
|
Tidal as TidalApiClient,
|
||||||
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",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -66,7 +66,6 @@ def _format_total_seconds(seconds: Any) -> str:
|
|||||||
|
|
||||||
class Tidal(Provider):
|
class Tidal(Provider):
|
||||||
PROVIDER_NAME = "tidal"
|
PROVIDER_NAME = "tidal"
|
||||||
PROVIDER_ALIASES = ("tidal",)
|
|
||||||
|
|
||||||
TABLE_AUTO_STAGES = {
|
TABLE_AUTO_STAGES = {
|
||||||
"tidal.track": ["download-file"],
|
"tidal.track": ["download-file"],
|
||||||
@@ -85,13 +84,18 @@ class Tidal(Provider):
|
|||||||
"listen.tidal.com",
|
"listen.tidal.com",
|
||||||
)
|
)
|
||||||
URL = URL_DOMAINS
|
URL = URL_DOMAINS
|
||||||
"""Provider that targets the Tidal-RestAPI (Tidal proxy) search endpoint.
|
"""Provider that targets the Tidal search endpoint.
|
||||||
|
|
||||||
The CLI can supply a list of fail-over URLs via ``provider.tidal.api_urls`` or
|
The CLI can supply a list of fail-over URLs via ``provider.tidal.api_urls`` or
|
||||||
``provider.tidal.api_url`` in the config. When not configured, it defaults to
|
``provider.tidal.api_url`` in the config. When not configured, it defaults to
|
||||||
https://tidal-api.binimum.org.
|
https://tidal-api.binimum.org.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
_stringify = staticmethod(stringify)
|
||||||
|
_extract_artists = staticmethod(extract_artists)
|
||||||
|
_build_track_tags = staticmethod(build_track_tags)
|
||||||
|
_coerce_duration_seconds = staticmethod(coerce_duration_seconds)
|
||||||
|
|
||||||
def __init__(self, config: Optional[Dict[str, Any]] = None) -> None:
|
def __init__(self, config: Optional[Dict[str, Any]] = None) -> None:
|
||||||
super().__init__(config)
|
super().__init__(config)
|
||||||
self.api_urls = self._resolve_api_urls()
|
self.api_urls = self._resolve_api_urls()
|
||||||
@@ -99,7 +103,7 @@ class Tidal(Provider):
|
|||||||
self.api_timeout = float(self.config.get("timeout", 10.0))
|
self.api_timeout = float(self.config.get("timeout", 10.0))
|
||||||
except Exception:
|
except Exception:
|
||||||
self.api_timeout = 10.0
|
self.api_timeout = 10.0
|
||||||
self.api_clients = [tidalApiClient(base_url=url, timeout=self.api_timeout) for url in self.api_urls]
|
self.api_clients = [TidalApiClient(base_url=url, timeout=self.api_timeout) for url in self.api_urls]
|
||||||
|
|
||||||
def extract_query_arguments(self, query: str) -> Tuple[str, Dict[str, Any]]:
|
def extract_query_arguments(self, query: str) -> Tuple[str, Dict[str, Any]]:
|
||||||
normalized, parsed = parse_inline_query_arguments(query)
|
normalized, parsed = parse_inline_query_arguments(query)
|
||||||
@@ -239,6 +243,16 @@ class Tidal(Provider):
|
|||||||
except Exception:
|
except Exception:
|
||||||
return "", None
|
return "", None
|
||||||
|
|
||||||
|
scheme = str(parsed.scheme or "").lower().strip()
|
||||||
|
if scheme == "tidal":
|
||||||
|
# Handle tidal://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
|
||||||
@@ -250,7 +264,7 @@ class Tidal(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:]:
|
||||||
@@ -271,7 +285,7 @@ class Tidal(Provider):
|
|||||||
|
|
||||||
title = f"Track {track_id}"
|
title = f"Track {track_id}"
|
||||||
if isinstance(detail, dict):
|
if isinstance(detail, dict):
|
||||||
title = self._stringify(detail.get("title")) or title
|
title = stringify(detail.get("title")) or title
|
||||||
|
|
||||||
return SearchResult(
|
return SearchResult(
|
||||||
table="tidal.track",
|
table="tidal.track",
|
||||||
@@ -281,6 +295,7 @@ class Tidal(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"tidal://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]]:
|
||||||
@@ -364,24 +379,24 @@ class Tidal(Provider):
|
|||||||
if not isinstance(meta, dict):
|
if not isinstance(meta, dict):
|
||||||
meta = {}
|
meta = {}
|
||||||
|
|
||||||
album_title = self._stringify(payload.get("title") or meta.get("title") or meta.get("name"))
|
album_title = stringify(payload.get("title") or meta.get("title") or meta.get("name"))
|
||||||
if not album_title:
|
if not album_title:
|
||||||
album_title = self._stringify(meta.get("album") or meta.get("albumTitle"))
|
album_title = stringify(meta.get("album") or meta.get("albumTitle"))
|
||||||
if not album_title:
|
if not album_title:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
artist_name = self._stringify(meta.get("_artist_name") or meta.get("artist") or meta.get("artistName"))
|
artist_name = stringify(meta.get("_artist_name") or meta.get("artist") or meta.get("artistName"))
|
||||||
if not artist_name:
|
if not artist_name:
|
||||||
# Some album payloads include nested artist objects.
|
# Some album payloads include nested artist objects.
|
||||||
artist_obj = meta.get("artist")
|
artist_obj = meta.get("artist")
|
||||||
if isinstance(artist_obj, dict):
|
if isinstance(artist_obj, dict):
|
||||||
artist_name = self._stringify(artist_obj.get("name"))
|
artist_name = stringify(artist_obj.get("name"))
|
||||||
|
|
||||||
# Prefer albumId when available; some payloads carry both id/albumId.
|
# Prefer albumId when available; some payloads carry both id/albumId.
|
||||||
album_id = self._parse_int(meta.get("albumId") or meta.get("id"))
|
album_id = self._parse_int(meta.get("albumId") or meta.get("id"))
|
||||||
|
|
||||||
if not album_id:
|
if not album_id:
|
||||||
raw_path = self._stringify(payload.get("path"))
|
raw_path = stringify(payload.get("path"))
|
||||||
if raw_path:
|
if raw_path:
|
||||||
m = re.search(r"tidal:(?://)?album[\\/](\d+)", raw_path, flags=re.IGNORECASE)
|
m = re.search(r"tidal:(?://)?album[\\/](\d+)", raw_path, flags=re.IGNORECASE)
|
||||||
if m:
|
if m:
|
||||||
@@ -428,7 +443,7 @@ class Tidal(Provider):
|
|||||||
# Fallback: string-match extracted display.
|
# Fallback: string-match extracted display.
|
||||||
if wanted:
|
if wanted:
|
||||||
try:
|
try:
|
||||||
names = [n.lower() for n in self._extract_artists(track)]
|
names = [n.lower() for n in extract_artists(track)]
|
||||||
except Exception:
|
except Exception:
|
||||||
names = []
|
names = []
|
||||||
return wanted in names
|
return wanted in names
|
||||||
@@ -470,7 +485,7 @@ class Tidal(Provider):
|
|||||||
continue
|
continue
|
||||||
# Prefer albumId when available; some payloads carry both id/albumId.
|
# Prefer albumId when available; some payloads carry both id/albumId.
|
||||||
album_id = self._parse_int(album.get("albumId") or album.get("id"))
|
album_id = self._parse_int(album.get("albumId") or album.get("id"))
|
||||||
title = self._stringify(album.get("title"))
|
title = stringify(album.get("title"))
|
||||||
if not title:
|
if not title:
|
||||||
continue
|
continue
|
||||||
if album_id:
|
if album_id:
|
||||||
@@ -614,7 +629,7 @@ class Tidal(Provider):
|
|||||||
if album_id and self._parse_int(album.get("albumId") or album.get("id")) == album_id:
|
if album_id and self._parse_int(album.get("albumId") or album.get("id")) == album_id:
|
||||||
album_ok = True
|
album_ok = True
|
||||||
else:
|
else:
|
||||||
at = self._stringify(album.get("title")).lower()
|
at = stringify(album.get("title")).lower()
|
||||||
if at:
|
if at:
|
||||||
if at == wanted_album:
|
if at == wanted_album:
|
||||||
album_ok = True
|
album_ok = True
|
||||||
@@ -627,7 +642,7 @@ class Tidal(Provider):
|
|||||||
album_ok = True
|
album_ok = True
|
||||||
else:
|
else:
|
||||||
# If album is not a dict, fall back to string compare.
|
# If album is not a dict, fall back to string compare.
|
||||||
at = self._stringify(track.get("album")).lower()
|
at = stringify(track.get("album")).lower()
|
||||||
if at:
|
if at:
|
||||||
if at == wanted_album:
|
if at == wanted_album:
|
||||||
album_ok = True
|
album_ok = True
|
||||||
@@ -664,9 +679,9 @@ class Tidal(Provider):
|
|||||||
|
|
||||||
album = track.get("album")
|
album = track.get("album")
|
||||||
if isinstance(album, dict):
|
if isinstance(album, dict):
|
||||||
at = self._stringify(album.get("title")).lower()
|
at = stringify(album.get("title")).lower()
|
||||||
else:
|
else:
|
||||||
at = self._stringify(track.get("album")).lower()
|
at = stringify(track.get("album")).lower()
|
||||||
|
|
||||||
if not at:
|
if not at:
|
||||||
continue
|
continue
|
||||||
@@ -765,7 +780,7 @@ class Tidal(Provider):
|
|||||||
def _album_item_to_result(self, album: Dict[str, Any], *, artist_name: str) -> Optional[SearchResult]:
|
def _album_item_to_result(self, album: Dict[str, Any], *, artist_name: str) -> Optional[SearchResult]:
|
||||||
if not isinstance(album, dict):
|
if not isinstance(album, dict):
|
||||||
return None
|
return None
|
||||||
title = self._stringify(album.get("title"))
|
title = stringify(album.get("title"))
|
||||||
if not title:
|
if not title:
|
||||||
return None
|
return None
|
||||||
# Prefer albumId when available; some payloads carry both id/albumId.
|
# Prefer albumId when available; some payloads carry both id/albumId.
|
||||||
@@ -784,7 +799,7 @@ class Tidal(Provider):
|
|||||||
if total_time:
|
if total_time:
|
||||||
columns.append(("Total", total_time))
|
columns.append(("Total", total_time))
|
||||||
|
|
||||||
release_date = self._stringify(album.get("releaseDate") or album.get("release_date") or album.get("date"))
|
release_date = stringify(album.get("releaseDate") or album.get("release_date") or album.get("date"))
|
||||||
if release_date:
|
if release_date:
|
||||||
columns.append(("Release", release_date))
|
columns.append(("Release", release_date))
|
||||||
|
|
||||||
@@ -804,6 +819,11 @@ class Tidal(Provider):
|
|||||||
full_metadata=md,
|
full_metadata=md,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def url_patterns() -> List[str]:
|
||||||
|
"""Return URL prefixes handled by this provider."""
|
||||||
|
return ["tidal://", "tidal.com"]
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _find_ffmpeg() -> Optional[str]:
|
def _find_ffmpeg() -> Optional[str]:
|
||||||
exe = shutil.which("ffmpeg")
|
exe = shutil.which("ffmpeg")
|
||||||
@@ -936,7 +956,7 @@ class Tidal(Provider):
|
|||||||
dur = int(duration_seconds) if duration_seconds is not None else None
|
dur = int(duration_seconds) if duration_seconds is not None else None
|
||||||
except Exception:
|
except Exception:
|
||||||
dur = None
|
dur = None
|
||||||
if not dur or dur <= 0:
|
if not dur or dur <= 0:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
qual = str(audio_quality or "").strip().lower()
|
qual = str(audio_quality or "").strip().lower()
|
||||||
@@ -1115,34 +1135,28 @@ class Tidal(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:
|
||||||
@@ -1190,7 +1204,7 @@ class Tidal(Provider):
|
|||||||
output_path=out_file,
|
output_path=out_file,
|
||||||
progress=self.config.get("_pipeline_progress") if isinstance(self.config, dict) else None,
|
progress=self.config.get("_pipeline_progress") if isinstance(self.config, dict) else None,
|
||||||
transfer_label=title_part or getattr(result, "title", None),
|
transfer_label=title_part or getattr(result, "title", None),
|
||||||
duration_seconds=self._coerce_duration_seconds(md),
|
duration_seconds=coerce_duration_seconds(md),
|
||||||
audio_quality=md.get("audioQuality") if isinstance(md, dict) else None,
|
audio_quality=md.get("audioQuality") if isinstance(md, dict) else None,
|
||||||
)
|
)
|
||||||
if materialized is not None:
|
if materialized is not None:
|
||||||
@@ -1223,7 +1237,7 @@ class Tidal(Provider):
|
|||||||
output_path=out_file,
|
output_path=out_file,
|
||||||
progress=self.config.get("_pipeline_progress") if isinstance(self.config, dict) else None,
|
progress=self.config.get("_pipeline_progress") if isinstance(self.config, dict) else None,
|
||||||
transfer_label=title_part or getattr(result, "title", None),
|
transfer_label=title_part or getattr(result, "title", None),
|
||||||
duration_seconds=self._coerce_duration_seconds(md),
|
duration_seconds=coerce_duration_seconds(md),
|
||||||
audio_quality=md.get("audioQuality") if isinstance(md, dict) else None,
|
audio_quality=md.get("audioQuality") if isinstance(md, dict) else None,
|
||||||
)
|
)
|
||||||
if materialized is not None:
|
if materialized is not None:
|
||||||
@@ -1248,7 +1262,7 @@ class Tidal(Provider):
|
|||||||
output_path=out_file,
|
output_path=out_file,
|
||||||
progress=self.config.get("_pipeline_progress") if isinstance(self.config, dict) else None,
|
progress=self.config.get("_pipeline_progress") if isinstance(self.config, dict) else None,
|
||||||
transfer_label=title_part or getattr(result, "title", None),
|
transfer_label=title_part or getattr(result, "title", None),
|
||||||
duration_seconds=self._coerce_duration_seconds(md),
|
duration_seconds=coerce_duration_seconds(md),
|
||||||
audio_quality=md.get("audioQuality") if isinstance(md, dict) else None,
|
audio_quality=md.get("audioQuality") if isinstance(md, dict) else None,
|
||||||
)
|
)
|
||||||
return materialized
|
return materialized
|
||||||
@@ -1300,10 +1314,10 @@ class Tidal(Provider):
|
|||||||
if isinstance(metadata, dict):
|
if isinstance(metadata, dict):
|
||||||
album_obj = metadata.get("album")
|
album_obj = metadata.get("album")
|
||||||
if isinstance(album_obj, dict):
|
if isinstance(album_obj, dict):
|
||||||
album_title = self._stringify(album_obj.get("title"))
|
album_title = stringify(album_obj.get("title"))
|
||||||
else:
|
else:
|
||||||
album_title = self._stringify(album_obj or metadata.get("album"))
|
album_title = stringify(album_obj or metadata.get("album"))
|
||||||
artists = self._extract_artists(metadata)
|
artists = extract_artists(metadata)
|
||||||
if artists:
|
if artists:
|
||||||
artist_name = artists[0]
|
artist_name = artists[0]
|
||||||
|
|
||||||
@@ -1320,7 +1334,7 @@ class Tidal(Provider):
|
|||||||
|
|
||||||
return False, None
|
return False, None
|
||||||
|
|
||||||
def _get_api_client_for_base(self, base_url: str) -> Optional[tidalApiClient]:
|
def _get_api_client_for_base(self, base_url: str) -> Optional[TidalApiClient]:
|
||||||
base = base_url.rstrip("/")
|
base = base_url.rstrip("/")
|
||||||
for client in self.api_clients:
|
for client in self.api_clients:
|
||||||
if getattr(client, "base_url", "").rstrip("/") == base:
|
if getattr(client, "base_url", "").rstrip("/") == base:
|
||||||
@@ -1551,18 +1565,18 @@ class Tidal(Provider):
|
|||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
path = f"tidal://artist/{artist_id}"
|
path = f"hifi://artist/{artist_id}"
|
||||||
|
|
||||||
columns: List[tuple[str, str]] = [("Artist", name), ("Artist ID", str(artist_id))]
|
columns: List[tuple[str, str]] = [("Artist", name), ("Artist ID", str(artist_id))]
|
||||||
popularity = self._stringify(item.get("popularity"))
|
popularity = stringify(item.get("popularity"))
|
||||||
if popularity:
|
if popularity:
|
||||||
columns.append(("Popularity", popularity))
|
columns.append(("Popularity", popularity))
|
||||||
|
|
||||||
return SearchResult(
|
return SearchResult(
|
||||||
table="tidal.artist",
|
table="hifi.artist",
|
||||||
title=name,
|
title=name,
|
||||||
path=path,
|
path=path,
|
||||||
detail="tidal.artist",
|
detail="hifi.artist",
|
||||||
annotations=["tidal", "artist"],
|
annotations=["tidal", "artist"],
|
||||||
media_kind="audio",
|
media_kind="audio",
|
||||||
columns=columns,
|
columns=columns,
|
||||||
@@ -1580,18 +1594,6 @@ class Tidal(Provider):
|
|||||||
minutes, secs = divmod(total, 60)
|
minutes, secs = divmod(total, 60)
|
||||||
return f"{minutes}:{secs:02d}"
|
return f"{minutes}:{secs:02d}"
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _coerce_duration_seconds(value: Any) -> Optional[int]:
|
|
||||||
return coerce_duration_seconds(value)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _stringify(value: Any) -> str:
|
|
||||||
return stringify(value)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _extract_artists(item: Dict[str, Any]) -> List[str]:
|
|
||||||
return extract_artists(item)
|
|
||||||
|
|
||||||
def _item_to_result(self, item: Dict[str, Any]) -> Optional[SearchResult]:
|
def _item_to_result(self, item: Dict[str, Any]) -> Optional[SearchResult]:
|
||||||
if not isinstance(item, dict):
|
if not isinstance(item, dict):
|
||||||
return None
|
return None
|
||||||
@@ -1609,9 +1611,9 @@ class Tidal(Provider):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
# Avoid tidal.com URLs entirely; selection will resolve to a decoded MPD.
|
# Avoid tidal.com URLs entirely; selection will resolve to a decoded MPD.
|
||||||
path = f"tidal://track/{track_id}"
|
path = f"hifi://track/{track_id}"
|
||||||
|
|
||||||
artists = self._extract_artists(item)
|
artists = extract_artists(item)
|
||||||
artist_display = ", ".join(artists)
|
artist_display = ", ".join(artists)
|
||||||
|
|
||||||
album = item.get("album")
|
album = item.get("album")
|
||||||
@@ -1629,8 +1631,8 @@ class Tidal(Provider):
|
|||||||
columns: List[tuple[str, str]] = []
|
columns: List[tuple[str, str]] = []
|
||||||
if title:
|
if title:
|
||||||
columns.append(("Title", title))
|
columns.append(("Title", title))
|
||||||
disc_no = self._stringify(item.get("volumeNumber") or item.get("discNumber") or item.get("disc_number"))
|
disc_no = stringify(item.get("volumeNumber") or item.get("discNumber") or item.get("disc_number"))
|
||||||
track_no = self._stringify(item.get("trackNumber") or item.get("track_number"))
|
track_no = stringify(item.get("trackNumber") or item.get("track_number"))
|
||||||
if disc_no:
|
if disc_no:
|
||||||
columns.append(("Disc #", disc_no))
|
columns.append(("Disc #", disc_no))
|
||||||
if track_no:
|
if track_no:
|
||||||
@@ -1651,22 +1653,23 @@ class Tidal(Provider):
|
|||||||
# manifest path/URL. If multiple results share the same dict reference,
|
# manifest path/URL. If multiple results share the same dict reference,
|
||||||
# they can incorrectly collapse to a single playable target.
|
# they can incorrectly collapse to a single playable target.
|
||||||
full_md: Dict[str, Any] = dict(item)
|
full_md: Dict[str, Any] = dict(item)
|
||||||
url_value = self._stringify(full_md.get("url"))
|
url_value = stringify(full_md.get("url"))
|
||||||
if url_value:
|
if url_value:
|
||||||
full_md["url"] = url_value
|
full_md["url"] = url_value
|
||||||
|
|
||||||
tags = self._build_track_tags(full_md)
|
tags = build_track_tags(full_md)
|
||||||
|
|
||||||
result = SearchResult(
|
result = SearchResult(
|
||||||
table="tidal.track",
|
table="hifi.track",
|
||||||
title=title,
|
title=title,
|
||||||
path=path,
|
path=path,
|
||||||
detail="tidal.track",
|
detail="hifi.track",
|
||||||
annotations=["tidal", "track"],
|
annotations=["tidal", "track"],
|
||||||
media_kind="audio",
|
media_kind="audio",
|
||||||
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:
|
||||||
@@ -1735,91 +1738,52 @@ class Tidal(Provider):
|
|||||||
path = (
|
path = (
|
||||||
payload.get("path")
|
payload.get("path")
|
||||||
or payload.get("url")
|
or payload.get("url")
|
||||||
or f"tidal://track/{track_id}"
|
or f"hifi://track/{track_id}"
|
||||||
)
|
)
|
||||||
contexts.append((track_id, str(title).strip(), str(path).strip()))
|
contexts.append((track_id, str(title).strip(), str(path).strip()))
|
||||||
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)
|
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."""
|
||||||
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"[tidal] Track lookup failed for {endpoint}: {exc}", file=sys.stderr)
|
|
||||||
continue
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _fetch_track_info(self, track_id: int) -> Optional[Dict[str, Any]]:
|
|
||||||
if track_id <= 0:
|
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"[tidal] Info lookup failed for {endpoint}: {exc}")
|
debug(f"[tidal] 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"[tidal] 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]] = [
|
||||||
("Track ID", str(track_id)),
|
("Track ID", str(track_id)),
|
||||||
("Quality", self._stringify(detail.get("audioQuality"))),
|
("Quality", stringify(detail.get("audioQuality"))),
|
||||||
("Mode", self._stringify(detail.get("audioMode"))),
|
("Mode", stringify(detail.get("audioMode"))),
|
||||||
("Asset", self._stringify(detail.get("assetPresentation"))),
|
("Asset", stringify(detail.get("assetPresentation"))),
|
||||||
("Manifest Type", self._stringify(detail.get("manifestMimeType"))),
|
("Manifest Type", stringify(detail.get("manifestMimeType"))),
|
||||||
("Manifest Hash", self._stringify(detail.get("manifestHash"))),
|
("Manifest Hash", stringify(detail.get("manifestHash"))),
|
||||||
("Bit Depth", self._stringify(detail.get("bitDepth"))),
|
("Bit Depth", stringify(detail.get("bitDepth"))),
|
||||||
("Sample Rate", self._stringify(detail.get("sampleRate"))),
|
("Sample Rate", stringify(detail.get("sampleRate"))),
|
||||||
]
|
]
|
||||||
return [(name, value) for name, value in values if value]
|
return [(name, value) for name, value in values if value]
|
||||||
|
|
||||||
def _build_track_tags(self, metadata: Dict[str, Any]) -> set[str]:
|
@staticmethod
|
||||||
return build_track_tags(metadata)
|
|
||||||
|
|
||||||
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]]:
|
||||||
@@ -2006,17 +1970,11 @@ class Tidal(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 (tidal.download) handles manifest resolution locally.
|
||||||
if table_type == "tidal.track" or (is_generic_tidal and any(str(get_field(i, "path")).startswith("tidal://track/") for i in selected_items)):
|
if table_type == "tidal.track" or (is_generic_tidal and any(str(get_field(i, "path")).startswith("tidal://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:
|
||||||
@@ -2049,34 +2007,24 @@ class Tidal(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"tidal://track/{track_id}"
|
||||||
try:
|
|
||||||
from cmdlet._shared import resolve_tidal_manifest_path
|
|
||||||
|
|
||||||
manifest_path = resolve_tidal_manifest_path(
|
artists = extract_artists(detail)
|
||||||
{"full_metadata": detail, "path": f"tidal://track/{track_id}"}
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
manifest_path = None
|
|
||||||
|
|
||||||
resolved_path = str(manifest_path) if manifest_path else f"tidal://track/{track_id}"
|
|
||||||
|
|
||||||
artists = self._extract_artists(detail)
|
|
||||||
artist_display = ", ".join(artists) if artists else ""
|
artist_display = ", ".join(artists) if artists else ""
|
||||||
columns = self._build_track_columns(detail, track_id)
|
columns = self._build_track_columns(detail, track_id)
|
||||||
if artist_display:
|
if artist_display:
|
||||||
columns.insert(1, ("Artist", artist_display))
|
columns.insert(1, ("Artist", artist_display))
|
||||||
album = detail.get("album")
|
album = detail.get("album")
|
||||||
if isinstance(album, dict):
|
if isinstance(album, dict):
|
||||||
album_title = self._stringify(album.get("title"))
|
album_title = stringify(album.get("title"))
|
||||||
else:
|
else:
|
||||||
album_title = self._stringify(detail.get("album"))
|
album_title = stringify(detail.get("album"))
|
||||||
if album_title:
|
if album_title:
|
||||||
insert_pos = 2 if artist_display else 1
|
insert_pos = 2 if artist_display else 1
|
||||||
columns.insert(insert_pos, ("Album", album_title))
|
columns.insert(insert_pos, ("Album", album_title))
|
||||||
|
|
||||||
tags = self._build_track_tags(detail)
|
tags = build_track_tags(detail)
|
||||||
url_value = self._stringify(detail.get("url"))
|
url_value = stringify(detail.get("url"))
|
||||||
|
|
||||||
result = SearchResult(
|
result = SearchResult(
|
||||||
table="tidal.track",
|
table="tidal.track",
|
||||||
@@ -2088,6 +2036,7 @@ class Tidal(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:
|
||||||
|
|||||||
Reference in New Issue
Block a user