This commit is contained in:
2026-01-03 21:23:55 -08:00
parent 73f3005393
commit 3acf21a673
10 changed files with 1244 additions and 43 deletions

View File

@@ -29,10 +29,22 @@ _DELIMITERS_RE = re.compile(r"[;,]")
_SEGMENT_BOUNDARY_RE = re.compile(r"(?=\b\w+\s*:)")
def _format_total_seconds(seconds: Any) -> str:
try:
total = int(seconds)
except Exception:
return ""
if total <= 0:
return ""
mins = total // 60
secs = total % 60
return f"{mins}:{secs:02d}"
class HIFI(Provider):
TABLE_AUTO_PREFIXES = {
"hifi": ["download-file"],
TABLE_AUTO_STAGES = {
"hifi.track": ["download-file"],
}
"""Provider that targets the HiFi-RestAPI (Tidal proxy) search endpoint.
@@ -62,6 +74,7 @@ class HIFI(Provider):
) -> List[SearchResult]:
if limit <= 0:
return []
view = self._get_view_from_query(query)
params = self._build_search_params(query)
if not params:
return []
@@ -82,17 +95,34 @@ class HIFI(Provider):
return []
data = payload.get("data") or {}
items = self._extract_track_items(data)
if view == "artist":
items = self._extract_artist_items(data)
else:
items = self._extract_track_items(data)
results: List[SearchResult] = []
for item in items:
if limit and len(results) >= limit:
break
result = self._item_to_result(item)
if view == "artist":
result = self._artist_item_to_result(item)
else:
result = self._item_to_result(item)
if result is not None:
results.append(result)
return results[:limit]
@staticmethod
def _get_view_from_query(query: str) -> str:
text = str(query or "").strip()
if not text:
return "track"
if re.search(r"\bartist\s*:", text, flags=re.IGNORECASE):
return "artist"
if re.search(r"\balbum\s*:", text, flags=re.IGNORECASE):
return "album"
return "track"
@staticmethod
def _safe_filename(value: Any, *, fallback: str = "hifi") -> str:
text = str(value or "").strip()
@@ -126,6 +156,478 @@ class HIFI(Provider):
return self._parse_track_id(m.group(1))
return None
@staticmethod
def _parse_int(value: Any) -> Optional[int]:
if value is None:
return None
try:
num = int(value)
except Exception:
return None
return num if num > 0 else None
def _extract_artist_selection_context(self, selected_items: List[Any]) -> List[Tuple[int, str]]:
contexts: List[Tuple[int, str]] = []
seen: set[int] = set()
for item in selected_items or []:
payload: Dict[str, Any] = {}
if isinstance(item, dict):
payload = item
else:
try:
payload = item.to_dict() if hasattr(item, "to_dict") and callable(getattr(item, "to_dict")) else {}
except Exception:
payload = {}
if not payload:
try:
payload = {
"title": getattr(item, "title", None),
"path": getattr(item, "path", None),
"full_metadata": getattr(item, "full_metadata", None),
}
except Exception:
payload = {}
meta = payload.get("full_metadata") if isinstance(payload.get("full_metadata"), dict) else payload
if not isinstance(meta, dict):
meta = {}
artist_id = self._parse_int(meta.get("artistId") or meta.get("id") or payload.get("artistId") or payload.get("id"))
if not artist_id:
# Try to parse from path.
raw_path = str(payload.get("path") or "").strip()
if raw_path:
m = re.search(r"hifi:(?://)?artist[\\/](\d+)", raw_path, flags=re.IGNORECASE)
if m:
artist_id = self._parse_int(m.group(1))
if not artist_id or artist_id in seen:
continue
seen.add(artist_id)
name = (
payload.get("title")
or meta.get("name")
or meta.get("title")
or payload.get("name")
)
name_text = str(name or "").strip() or f"Artist {artist_id}"
contexts.append((artist_id, name_text))
return contexts
def _extract_album_selection_context(self, selected_items: List[Any]) -> List[Tuple[Optional[int], str, str]]:
"""Return (album_id, album_title, artist_name) for selected album rows."""
contexts: List[Tuple[Optional[int], str, str]] = []
seen_ids: set[int] = set()
seen_keys: set[str] = set()
for item in selected_items or []:
payload: Dict[str, Any] = {}
if isinstance(item, dict):
payload = item
else:
try:
payload = item.to_dict() if hasattr(item, "to_dict") and callable(getattr(item, "to_dict")) else {}
except Exception:
payload = {}
if not payload:
try:
payload = {
"title": getattr(item, "title", None),
"path": getattr(item, "path", None),
"full_metadata": getattr(item, "full_metadata", None),
}
except Exception:
payload = {}
meta = payload.get("full_metadata") if isinstance(payload.get("full_metadata"), dict) else payload
if not isinstance(meta, dict):
meta = {}
album_title = self._stringify(payload.get("title") or meta.get("title") or meta.get("name"))
if not album_title:
album_title = self._stringify(meta.get("album") or meta.get("albumTitle"))
if not album_title:
continue
artist_name = self._stringify(meta.get("_artist_name") or meta.get("artist") or meta.get("artistName"))
if not artist_name:
# Some album payloads include nested artist objects.
artist_obj = meta.get("artist")
if isinstance(artist_obj, dict):
artist_name = self._stringify(artist_obj.get("name"))
# Prefer albumId when available; some payloads carry both id/albumId.
album_id = self._parse_int(meta.get("albumId") or meta.get("id"))
if not album_id:
raw_path = self._stringify(payload.get("path"))
if raw_path:
m = re.search(r"hifi:(?://)?album[\\/](\d+)", raw_path, flags=re.IGNORECASE)
if m:
album_id = self._parse_int(m.group(1))
if album_id:
if album_id in seen_ids:
continue
seen_ids.add(album_id)
else:
key = f"{album_title.lower()}::{artist_name.lower()}"
if key in seen_keys:
continue
seen_keys.add(key)
contexts.append((album_id, album_title, artist_name))
return contexts
def _track_matches_artist(self, track: Dict[str, Any], *, artist_id: Optional[int], artist_name: str) -> bool:
if not isinstance(track, dict):
return False
wanted = str(artist_name or "").strip().lower()
primary = track.get("artist")
if isinstance(primary, dict):
if artist_id and self._parse_int(primary.get("id")) == artist_id:
return True
name = str(primary.get("name") or "").strip().lower()
if wanted and name == wanted:
return True
artists = track.get("artists")
if isinstance(artists, list):
for a in artists:
if not isinstance(a, dict):
continue
if artist_id and self._parse_int(a.get("id")) == artist_id:
return True
name = str(a.get("name") or "").strip().lower()
if wanted and name == wanted:
return True
# Fallback: string-match extracted display.
if wanted:
try:
names = [n.lower() for n in self._extract_artists(track)]
except Exception:
names = []
return wanted in names
return False
def _albums_for_artist(self, *, artist_id: Optional[int], artist_name: str, limit: int = 200) -> List[SearchResult]:
name = str(artist_name or "").strip()
if not name:
return []
payload: Optional[Dict[str, Any]] = None
for base in self.api_urls:
endpoint = f"{base.rstrip('/')}/search/"
try:
client = self._get_api_client_for_base(base)
payload = client.search({"s": name}) if client else None
if payload is not None:
break
except Exception as exc:
log(f"[hifi] Album lookup failed for {endpoint}: {exc}", file=sys.stderr)
continue
if not payload:
return []
data = payload.get("data") or {}
tracks = self._extract_track_items(data)
if not tracks:
return []
albums_by_id: Dict[int, Dict[str, Any]] = {}
albums_by_key: Dict[str, Dict[str, Any]] = {}
for track in tracks:
if not self._track_matches_artist(track, artist_id=artist_id, artist_name=name):
continue
album = track.get("album")
if not isinstance(album, dict):
continue
# Prefer albumId when available; some payloads carry both id/albumId.
album_id = self._parse_int(album.get("albumId") or album.get("id"))
title = self._stringify(album.get("title"))
if not title:
continue
if album_id:
albums_by_id.setdefault(album_id, album)
continue
key = f"{title.lower()}::{name.lower()}"
albums_by_key.setdefault(key, album)
album_items: List[Dict[str, Any]] = list(albums_by_id.values()) + list(albums_by_key.values())
results: List[SearchResult] = []
for album in album_items:
if limit and len(results) >= limit:
break
res = self._album_item_to_result(album, artist_name=name)
if res is not None:
results.append(res)
return results
def _tracks_for_album(self, *, album_id: Optional[int], album_title: str, artist_name: str = "", limit: int = 200) -> List[SearchResult]:
title = str(album_title or "").strip()
if not title:
return []
def _norm_album(text: str) -> str:
# Normalize album titles for matching across punctuation/case/spacing.
# Example: "either/or" vs "Either Or" or "Either/Or (Expanded Edition)".
s = str(text or "").strip().lower()
if not s:
return ""
s = re.sub(r"&", " and ", s)
s = re.sub(r"[^a-z0-9]+", "", s)
return s
search_text = title
artist_text = str(artist_name or "").strip()
if artist_text:
# The proxy only supports s/a/v/p. Use a combined s= query to bias results
# toward the target album's tracks.
search_text = f"{artist_text} {title}".strip()
# Prefer /album when we have a numeric album id.
# The proxy returns the album payload including a full track list in `data.items`.
# When this endpoint is available, it is authoritative for an album id, so we do
# not apply additional title/artist filtering.
if album_id:
for base in self.api_urls:
endpoint = f"{base.rstrip('/')}/album/"
try:
client = self._get_api_client_for_base(base)
album_payload = client.album(int(album_id)) if client else None
except Exception as exc:
log(f"[hifi] Album lookup failed for {endpoint}: {exc}", file=sys.stderr)
continue
if not isinstance(album_payload, dict) or not album_payload:
continue
try:
album_data = album_payload.get("data")
album_tracks = self._extract_track_items(album_data if album_data is not None else album_payload)
except Exception:
album_tracks = []
if not album_tracks:
# Try the next configured base URL (some backends return an error-shaped
# JSON object with 200, or omit tracks for certain ids).
continue
ordered: List[Tuple[int, int, Dict[str, Any]]] = []
for tr in album_tracks:
if not isinstance(tr, dict):
continue
disc_val = self._parse_int(tr.get("volumeNumber") or tr.get("discNumber") or 0) or 0
track_val = self._parse_int(tr.get("trackNumber") or 0) or 0
ordered.append((disc_val, track_val, tr))
ordered.sort(key=lambda t: (t[0], t[1]))
try:
debug(f"hifi album endpoint tracks: album_id={album_id} extracted={len(album_tracks)}")
except Exception:
pass
results: List[SearchResult] = []
for _disc, _track, tr in ordered:
if limit and len(results) >= limit:
break
res = self._item_to_result(tr)
if res is not None:
results.append(res)
if results:
return results
# Reduce punctuation in the raw search string to improve /search/ recall.
try:
search_text = re.sub(r"[/\\]+", " ", search_text)
search_text = re.sub(r"\s+", " ", search_text).strip()
except Exception:
pass
payload: Optional[Dict[str, Any]] = None
for base in self.api_urls:
endpoint = f"{base.rstrip('/')}/search/"
try:
client = self._get_api_client_for_base(base)
payload = client.search({"s": search_text}) if client else None
if payload is not None:
break
except Exception as exc:
log(f"[hifi] Track lookup failed for {endpoint}: {exc}", file=sys.stderr)
continue
if not payload:
return []
data = payload.get("data") or {}
tracks = self._extract_track_items(data)
if not tracks:
return []
try:
debug(f"hifi album search tracks: album_id={album_id} extracted={len(tracks)} query={repr(search_text)}")
except Exception:
pass
wanted_album = title.lower()
wanted_album_norm = _norm_album(title)
wanted_artist = artist_text.lower()
seen_ids: set[int] = set()
candidates: List[Tuple[int, int, Dict[str, Any]]] = []
for track in tracks:
if not isinstance(track, dict):
continue
tid = self._parse_int(track.get("id") or track.get("trackId"))
if not tid or tid in seen_ids:
continue
album = track.get("album")
album_ok = False
if isinstance(album, dict):
if album_id and self._parse_int(album.get("albumId") or album.get("id")) == album_id:
album_ok = True
else:
at = self._stringify(album.get("title")).lower()
if at:
if at == wanted_album:
album_ok = True
else:
at_norm = _norm_album(at)
if wanted_album_norm and at_norm and (
at_norm == wanted_album_norm
or wanted_album_norm in at_norm
or at_norm in wanted_album_norm):
album_ok = True
else:
# If album is not a dict, fall back to string compare.
at = self._stringify(track.get("album")).lower()
if at:
if at == wanted_album:
album_ok = True
else:
at_norm = _norm_album(at)
if wanted_album_norm and at_norm and (
at_norm == wanted_album_norm
or wanted_album_norm in at_norm
or at_norm in wanted_album_norm):
album_ok = True
if not album_ok:
continue
if wanted_artist:
if not self._track_matches_artist(track, artist_id=None, artist_name=artist_name):
continue
seen_ids.add(tid)
disc_val = self._parse_int(track.get("volumeNumber") or track.get("discNumber") or 0) or 0
track_val = self._parse_int(track.get("trackNumber") or 0) or 0
candidates.append((disc_val, track_val, track))
candidates.sort(key=lambda t: (t[0], t[1]))
# If strict matching found nothing, relax title matching (substring) while still
# keeping artist filtering when available.
if not candidates:
for track in tracks:
if not isinstance(track, dict):
continue
tid = self._parse_int(track.get("id") or track.get("trackId"))
if not tid or tid in seen_ids:
continue
album = track.get("album")
if isinstance(album, dict):
at = self._stringify(album.get("title")).lower()
else:
at = self._stringify(track.get("album")).lower()
if not at:
continue
at_norm = _norm_album(at)
if wanted_album_norm and at_norm:
if not (wanted_album_norm in at_norm or at_norm in wanted_album_norm):
continue
else:
if wanted_album not in at:
continue
if wanted_artist:
if not self._track_matches_artist(track, artist_id=None, artist_name=artist_name):
continue
seen_ids.add(tid)
disc_val = self._parse_int(track.get("volumeNumber") or track.get("discNumber") or 0) or 0
track_val = self._parse_int(track.get("trackNumber") or 0) or 0
candidates.append((disc_val, track_val, track))
candidates.sort(key=lambda t: (t[0], t[1]))
try:
debug(f"hifi album search tracks: album_id={album_id} matched={len(candidates)} title={repr(title)} artist={repr(artist_name)}")
except Exception:
pass
results: List[SearchResult] = []
for _disc, _track, track in candidates:
if limit and len(results) >= limit:
break
res = self._item_to_result(track)
if res is not None:
results.append(res)
return results
def _album_item_to_result(self, album: Dict[str, Any], *, artist_name: str) -> Optional[SearchResult]:
if not isinstance(album, dict):
return None
title = self._stringify(album.get("title"))
if not title:
return None
# Prefer albumId when available; some payloads carry both id/albumId.
album_id = self._parse_int(album.get("albumId") or album.get("id"))
path = f"hifi://album/{album_id}" if album_id else f"hifi://album/{self._safe_filename(title)}"
columns: List[tuple[str, str]] = [("Album", title)]
if artist_name:
columns.append(("Artist", str(artist_name)))
# Album stats (best-effort): show track count and total duration when available.
track_count = self._parse_int(album.get("numberOfTracks") or album.get("trackCount") or album.get("tracks") or 0)
if track_count:
columns.append(("Tracks", str(track_count)))
total_time = _format_total_seconds(album.get("duration") or album.get("durationSeconds") or album.get("duration_sec") or 0)
if total_time:
columns.append(("Total", total_time))
release_date = self._stringify(album.get("releaseDate") or album.get("release_date") or album.get("date"))
if release_date:
columns.append(("Release", release_date))
# Preserve the original album payload but add a hint for downstream.
md: Dict[str, Any] = dict(album)
if artist_name and "_artist_name" not in md:
md["_artist_name"] = artist_name
return SearchResult(
table="hifi",
title=title,
path=path,
detail="album",
annotations=["tidal", "album"],
media_kind="audio",
columns=columns,
full_metadata=md,
)
@staticmethod
def _find_ffmpeg() -> Optional[str]:
exe = shutil.which("ffmpeg")
@@ -475,20 +977,56 @@ class HIFI(Provider):
def _extract_track_items(self, data: Any) -> List[Dict[str, Any]]:
if isinstance(data, list):
return [item for item in data if isinstance(item, dict)]
items: List[Dict[str, Any]] = []
for item in data:
if not isinstance(item, dict):
continue
# Some endpoints return wrapper objects like {"item": {...}}.
nested = item.get("item")
if isinstance(nested, dict):
items.append(nested)
continue
nested = item.get("track")
if isinstance(nested, dict):
items.append(nested)
continue
items.append(item)
return items
if not isinstance(data, dict):
return []
items: List[Dict[str, Any]] = []
direct = data.get("items")
if isinstance(direct, list):
items.extend(item for item in direct if isinstance(item, dict))
for item in direct:
if not isinstance(item, dict):
continue
nested = item.get("item")
if isinstance(nested, dict):
items.append(nested)
continue
nested = item.get("track")
if isinstance(nested, dict):
items.append(nested)
continue
items.append(item)
tracks_section = data.get("tracks")
if isinstance(tracks_section, dict):
track_items = tracks_section.get("items")
if isinstance(track_items, list):
items.extend(item for item in track_items if isinstance(item, dict))
for item in track_items:
if not isinstance(item, dict):
continue
nested = item.get("item")
if isinstance(nested, dict):
items.append(nested)
continue
nested = item.get("track")
if isinstance(nested, dict):
items.append(nested)
continue
items.append(item)
top_hits = data.get("topHits")
if isinstance(top_hits, list):
@@ -563,20 +1101,121 @@ class HIFI(Provider):
if value:
key_values[key] = value
params: Dict[str, str] = {}
# The proxy API only accepts exactly one of s/a/v/p. If the user mixes
# free text with a structured key (e.g. artist:foo bar), treat the free
# text as part of the same query instead of creating an additional key.
mapped_values: Dict[str, List[str]] = {}
for key, value in key_values.items():
if not value:
continue
mapped = _KEY_TO_PARAM.get(key)
if mapped:
params[mapped] = value
if not mapped:
continue
mapped_values.setdefault(mapped, []).append(value)
general = " ".join(part for part in free_text if part).strip()
if general:
params.setdefault("s", general)
elif not params:
params["s"] = cleaned
return params
# Choose the search key in priority order.
chosen_key = None
for candidate in ("a", "v", "p", "s"):
if mapped_values.get(candidate):
chosen_key = candidate
break
if chosen_key is None:
chosen_key = "s"
chosen_parts: List[str] = []
chosen_parts.extend(mapped_values.get(chosen_key, []))
# If the user provided free text and a structured key (like artist:),
# fold it into the chosen key instead of forcing a second key.
extra = " ".join(part for part in free_text if part).strip()
if extra:
chosen_parts.append(extra)
chosen_value = " ".join(p for p in chosen_parts if p).strip()
if not chosen_value:
chosen_value = cleaned
return {chosen_key: chosen_value} if chosen_value else {}
def _extract_artist_items(self, data: Any) -> List[Dict[str, Any]]:
if isinstance(data, list):
return [item for item in data if isinstance(item, dict)]
if not isinstance(data, dict):
return []
items: List[Dict[str, Any]] = []
direct = data.get("items")
if isinstance(direct, list):
items.extend(item for item in direct if isinstance(item, dict))
artists_section = data.get("artists")
if isinstance(artists_section, dict):
artist_items = artists_section.get("items")
if isinstance(artist_items, list):
items.extend(item for item in artist_items if isinstance(item, dict))
top_hits = data.get("topHits")
if isinstance(top_hits, list):
for hit in top_hits:
if not isinstance(hit, dict):
continue
hit_type = str(hit.get("type") or "").upper()
if hit_type != "ARTISTS":
continue
value = hit.get("value")
if isinstance(value, dict):
items.append(value)
seen: set[int] = set()
deduped: List[Dict[str, Any]] = []
for item in items:
raw_id = item.get("id") or item.get("artistId")
if raw_id is None:
continue
try:
artist_int = int(raw_id)
except Exception:
artist_int = None
if artist_int is None or artist_int in seen:
continue
seen.add(artist_int)
deduped.append(item)
return deduped
def _artist_item_to_result(self, item: Dict[str, Any]) -> Optional[SearchResult]:
if not isinstance(item, dict):
return None
name = str(item.get("name") or item.get("title") or "").strip()
if not name:
return None
raw_id = item.get("id") or item.get("artistId")
if raw_id is None:
return None
try:
artist_id = int(raw_id)
except (TypeError, ValueError):
return None
path = f"hifi://artist/{artist_id}"
columns: List[tuple[str, str]] = [("Artist", name), ("Artist ID", str(artist_id))]
popularity = self._stringify(item.get("popularity"))
if popularity:
columns.append(("Popularity", popularity))
return SearchResult(
table="hifi",
title=name,
path=path,
detail="artist",
annotations=["tidal", "artist"],
media_kind="audio",
columns=columns,
full_metadata=item,
)
@staticmethod
def _format_duration(seconds: Any) -> str:
@@ -649,6 +1288,12 @@ class HIFI(Provider):
columns: List[tuple[str, str]] = []
if title:
columns.append(("Title", title))
disc_no = self._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"))
if disc_no:
columns.append(("Disc #", disc_no))
if track_no:
columns.append(("Track #", track_no))
if album_title:
columns.append(("Album", album_title))
if artist_display:
@@ -670,6 +1315,12 @@ class HIFI(Provider):
if isinstance(tag, str) and tag.strip():
tags.add(tag.strip().lower())
# IMPORTANT: do not retain a shared reference to the raw API dict.
# Downstream playback (MPV) mutates metadata to cache the decoded Tidal
# manifest path/URL. If multiple results share the same dict reference,
# they can incorrectly collapse to a single playable target.
full_md: Dict[str, Any] = dict(item)
return SearchResult(
table="hifi",
title=title,
@@ -679,7 +1330,7 @@ class HIFI(Provider):
media_kind="audio",
tag=tags,
columns=columns,
full_metadata=item,
full_metadata=full_md,
)
def _extract_track_selection_context(
@@ -814,11 +1465,133 @@ class HIFI(Provider):
current_table = ctx.get_current_stage_table()
except Exception:
current_table = None
if current_table is None:
try:
current_table = ctx.get_last_result_table()
except Exception:
current_table = None
table_type = (
current_table.table
if current_table and hasattr(current_table, "table")
else None
)
# Artist selection: selecting @N should open an albums list.
if isinstance(table_type, str) and table_type.strip().lower() == "hifi.artist":
contexts = self._extract_artist_selection_context(selected_items)
if not contexts:
return False
artist_id, artist_name = contexts[0]
album_results = self._albums_for_artist(artist_id=artist_id, artist_name=artist_name, limit=200)
if not album_results:
return False
try:
from SYS.rich_display import stdout_console
from SYS.result_table import ResultTable
except Exception:
return False
table = ResultTable(f"HIFI Albums: {artist_name}").set_preserve_order(False)
table.set_table("hifi.album")
try:
table.set_table_metadata({"provider": "hifi", "view": "album", "artist_id": artist_id, "artist_name": artist_name})
except Exception:
pass
results_payload: List[Dict[str, Any]] = []
for res in album_results:
table.add_result(res)
try:
results_payload.append(res.to_dict())
except Exception:
results_payload.append({"table": "hifi", "title": getattr(res, "title", ""), "path": getattr(res, "path", "")})
try:
ctx.set_last_result_table(table, results_payload)
ctx.set_current_stage_table(table)
except Exception:
pass
try:
suppress = bool(getattr(ctx, "_suppress_provider_selector_print", False))
except Exception:
suppress = False
if not suppress:
try:
stdout_console().print()
stdout_console().print(table)
except Exception:
pass
return True
# Album selection: selecting @N should open the track list for that album.
if isinstance(table_type, str) and table_type.strip().lower() == "hifi.album":
contexts = self._extract_album_selection_context(selected_items)
if not contexts:
return False
album_id, album_title, artist_name = contexts[0]
track_results = self._tracks_for_album(album_id=album_id, album_title=album_title, artist_name=artist_name, limit=200)
if not track_results:
return False
try:
from SYS.rich_display import stdout_console
from SYS.result_table import ResultTable
except Exception:
return False
label = album_title
if artist_name:
label = f"{artist_name} - {album_title}"
# Preserve album order (disc/track) rather than sorting by title.
table = ResultTable(f"HIFI Tracks: {label}").set_preserve_order(True)
table.set_table("hifi.track")
try:
table.set_table_metadata(
{
"provider": "hifi",
"view": "track",
"album_id": album_id,
"album_title": album_title,
"artist_name": artist_name,
}
)
except Exception:
pass
results_payload: List[Dict[str, Any]] = []
for res in track_results:
table.add_result(res)
try:
results_payload.append(res.to_dict())
except Exception:
results_payload.append({"table": "hifi", "title": getattr(res, "title", ""), "path": getattr(res, "path", "")})
try:
ctx.set_last_result_table(table, results_payload)
ctx.set_current_stage_table(table)
except Exception:
pass
try:
suppress = bool(getattr(ctx, "_suppress_provider_selector_print", False))
except Exception:
suppress = False
if not suppress:
try:
stdout_console().print()
stdout_console().print(table)
except Exception:
pass
return True
if isinstance(table_type, str) and table_type.strip().lower() == "hifi.track":
try:
meta = (

View File

@@ -780,9 +780,9 @@ class OpenLibrary(Provider):
elif isinstance(collection, str):
values = [collection.strip().lower()]
if any(v in {"inlibrary",
"printdisabled",
"lendinglibrary"} for v in values):
# Treat borrowable as "inlibrary" (and keep "lendinglibrary" as a safe alias).
# IMPORTANT: do NOT treat "printdisabled" alone as borrowable.
if any(v in {"inlibrary", "lendinglibrary"} for v in values):
return True, "archive-collection"
return False, "archive-not-lendable"
except Exception:
@@ -1312,11 +1312,11 @@ class OpenLibrary(Provider):
if lendable_local:
return "borrow", reason_local, archive_id_local, ""
# Not lendable: check whether it's directly downloadable (public domain uploads, etc.).
# OpenLibrary API can be a false-negative; fall back to Archive metadata.
try:
can_direct, pdf_url = self._archive_check_direct_download(archive_id_local)
if can_direct and pdf_url:
return "download", reason_local, archive_id_local, str(pdf_url)
lendable2, reason2 = self._archive_is_lendable(archive_id_local)
if lendable2:
return "borrow", reason2 or reason_local, archive_id_local, ""
except Exception:
pass
@@ -1401,6 +1401,11 @@ class OpenLibrary(Provider):
if 0 <= idx < len(availability_rows):
availability, availability_reason, archive_id, direct_url = availability_rows[idx]
# UX requirement: OpenLibrary provider should ONLY show borrowable books.
# Ignore printdisabled-only and non-borrow items.
if availability != "borrow":
continue
# Patch the display column.
for idx, (name, _val) in enumerate(columns):
if name == "Avail":