j
This commit is contained in:
434
Provider/HIFI.py
434
Provider/HIFI.py
@@ -1,12 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from API.hifi import HifiApiClient
|
||||
from ProviderCore.base import Provider, SearchResult
|
||||
from SYS.logger import log
|
||||
from SYS.logger import debug, log
|
||||
|
||||
DEFAULT_API_URLS = (
|
||||
"https://tidal-api.binimum.org",
|
||||
@@ -27,6 +30,10 @@ _SEGMENT_BOUNDARY_RE = re.compile(r"(?=\b\w+\s*:)")
|
||||
|
||||
|
||||
class HIFI(Provider):
|
||||
|
||||
TABLE_AUTO_PREFIXES = {
|
||||
"hifi": ["download-file"],
|
||||
}
|
||||
"""Provider that targets the HiFi-RestAPI (Tidal proxy) search endpoint.
|
||||
|
||||
The CLI can supply a list of fail-over URLs via ``provider.hifi.api_urls`` or
|
||||
@@ -86,6 +93,379 @@ class HIFI(Provider):
|
||||
|
||||
return results[:limit]
|
||||
|
||||
@staticmethod
|
||||
def _safe_filename(value: Any, *, fallback: str = "hifi") -> str:
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
return fallback
|
||||
text = re.sub(r"[<>:\"/\\|?*\x00-\x1f]", "_", text)
|
||||
text = re.sub(r"\s+", " ", text).strip().strip(". ")
|
||||
return text[:120] if text else fallback
|
||||
|
||||
@staticmethod
|
||||
def _parse_track_id(value: Any) -> Optional[int]:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
track_id = int(value)
|
||||
except Exception:
|
||||
return None
|
||||
return track_id if track_id > 0 else None
|
||||
|
||||
def _extract_track_id_from_result(self, result: SearchResult) -> Optional[int]:
|
||||
md = getattr(result, "full_metadata", None)
|
||||
if isinstance(md, dict):
|
||||
track_id = self._parse_track_id(md.get("trackId") or md.get("id"))
|
||||
if track_id:
|
||||
return track_id
|
||||
|
||||
path = str(getattr(result, "path", "") or "").strip()
|
||||
if path:
|
||||
m = re.search(r"hifi:(?://)?track[\\/](\d+)", path, flags=re.IGNORECASE)
|
||||
if m:
|
||||
return self._parse_track_id(m.group(1))
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _find_ffmpeg() -> Optional[str]:
|
||||
exe = shutil.which("ffmpeg")
|
||||
if exe:
|
||||
return exe
|
||||
try:
|
||||
repo_root = Path(__file__).resolve().parents[1]
|
||||
bundled = repo_root / "MPV" / "ffmpeg" / "bin" / "ffmpeg.exe"
|
||||
if bundled.is_file():
|
||||
return str(bundled)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _find_ffprobe() -> Optional[str]:
|
||||
exe = shutil.which("ffprobe")
|
||||
if exe:
|
||||
return exe
|
||||
try:
|
||||
repo_root = Path(__file__).resolve().parents[1]
|
||||
bundled = repo_root / "MPV" / "ffmpeg" / "bin" / "ffprobe.exe"
|
||||
if bundled.is_file():
|
||||
return str(bundled)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
def _probe_audio_codec(self, input_ref: str) -> Optional[str]:
|
||||
"""Best-effort probe for primary audio codec name (lowercase)."""
|
||||
|
||||
candidate = str(input_ref or "").strip()
|
||||
if not candidate:
|
||||
return None
|
||||
|
||||
ffprobe_path = self._find_ffprobe()
|
||||
if ffprobe_path:
|
||||
cmd = [
|
||||
ffprobe_path,
|
||||
"-v",
|
||||
"error",
|
||||
"-select_streams",
|
||||
"a:0",
|
||||
"-show_entries",
|
||||
"stream=codec_name",
|
||||
"-of",
|
||||
"default=nw=1:nk=1",
|
||||
candidate,
|
||||
]
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if proc.returncode == 0:
|
||||
codec = str(proc.stdout or "").strip().lower()
|
||||
if codec:
|
||||
return codec
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback: parse `ffmpeg -i` stream info.
|
||||
ffmpeg_path = self._find_ffmpeg()
|
||||
if not ffmpeg_path:
|
||||
return None
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[ffmpeg_path, "-hide_banner", "-i", candidate],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
text = (proc.stderr or "") + "\n" + (proc.stdout or "")
|
||||
m = re.search(r"Audio:\s*([A-Za-z0-9_]+)", text)
|
||||
if m:
|
||||
return str(m.group(1)).strip().lower()
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _preferred_audio_suffix(codec: Optional[str], metadata: Optional[Dict[str, Any]] = None) -> str:
|
||||
c = str(codec or "").strip().lower()
|
||||
if c == "flac":
|
||||
return ".flac"
|
||||
if c in {"aac", "alac"}:
|
||||
return ".m4a"
|
||||
# Default to Matroska Audio for unknown / uncommon codecs.
|
||||
return ".mka"
|
||||
|
||||
@staticmethod
|
||||
def _has_nonempty_file(path: Path) -> bool:
|
||||
try:
|
||||
return path.is_file() and path.stat().st_size > 0
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _ffmpeg_demux_to_audio(
|
||||
self,
|
||||
*,
|
||||
input_ref: str,
|
||||
output_path: Path,
|
||||
lossless_fallback: bool = True,
|
||||
) -> Optional[Path]:
|
||||
ffmpeg_path = self._find_ffmpeg()
|
||||
if not ffmpeg_path:
|
||||
debug("[hifi] ffmpeg not found; cannot materialize audio from MPD")
|
||||
return None
|
||||
|
||||
if self._has_nonempty_file(output_path):
|
||||
return output_path
|
||||
|
||||
try:
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
protocol_whitelist = "file,https,http,tcp,tls,crypto,data"
|
||||
|
||||
def _run(cmd: List[str]) -> bool:
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if proc.returncode == 0 and self._has_nonempty_file(output_path):
|
||||
return True
|
||||
if proc.stderr:
|
||||
debug(f"[hifi] ffmpeg failed: {proc.stderr.strip()}")
|
||||
except Exception as exc:
|
||||
debug(f"[hifi] ffmpeg invocation failed: {exc}")
|
||||
return False
|
||||
|
||||
# Prefer remux (fast, no transcode).
|
||||
cmd_copy = [
|
||||
ffmpeg_path,
|
||||
"-y",
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-protocol_whitelist",
|
||||
protocol_whitelist,
|
||||
"-i",
|
||||
str(input_ref),
|
||||
"-vn",
|
||||
"-c",
|
||||
"copy",
|
||||
str(output_path),
|
||||
]
|
||||
if _run(cmd_copy):
|
||||
return output_path
|
||||
|
||||
if not lossless_fallback:
|
||||
return None
|
||||
|
||||
# Fallback: decode/transcode to FLAC to guarantee a supported file.
|
||||
flac_path = (
|
||||
output_path
|
||||
if output_path.suffix.lower() == ".flac"
|
||||
else output_path.with_suffix(".flac")
|
||||
)
|
||||
if self._has_nonempty_file(flac_path):
|
||||
return flac_path
|
||||
|
||||
# Avoid leaving a partial FLAC behind if we're transcoding into the final name.
|
||||
tmp_flac_path = flac_path
|
||||
if flac_path == output_path:
|
||||
tmp_flac_path = output_path.with_name(f"{output_path.stem}.tmp{output_path.suffix}")
|
||||
|
||||
cmd_flac = [
|
||||
ffmpeg_path,
|
||||
"-y",
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-protocol_whitelist",
|
||||
protocol_whitelist,
|
||||
"-i",
|
||||
str(input_ref),
|
||||
"-vn",
|
||||
"-c:a",
|
||||
"flac",
|
||||
str(tmp_flac_path),
|
||||
]
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
cmd_flac,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if proc.returncode == 0 and self._has_nonempty_file(tmp_flac_path):
|
||||
if tmp_flac_path != flac_path:
|
||||
try:
|
||||
tmp_flac_path.replace(flac_path)
|
||||
except Exception:
|
||||
# If rename fails, still return the temp file.
|
||||
return tmp_flac_path
|
||||
return flac_path
|
||||
if proc.stderr:
|
||||
debug(f"[hifi] ffmpeg flac fallback failed: {proc.stderr.strip()}")
|
||||
except Exception as exc:
|
||||
debug(f"[hifi] ffmpeg flac fallback invocation failed: {exc}")
|
||||
return None
|
||||
|
||||
def download(self, result: SearchResult, output_dir: Path) -> Optional[Path]:
|
||||
"""Materialize a playable audio file from a Tidal DASH manifest."""
|
||||
|
||||
try:
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
raw_path = str(getattr(result, "path", "") or "").strip()
|
||||
|
||||
md: Dict[str, Any] = {}
|
||||
if isinstance(getattr(result, "full_metadata", None), dict):
|
||||
md = dict(getattr(result, "full_metadata") or {})
|
||||
|
||||
if not md.get("manifest"):
|
||||
track_id = self._extract_track_id_from_result(result)
|
||||
if track_id:
|
||||
detail = self._fetch_track_details(track_id)
|
||||
if isinstance(detail, dict) and detail:
|
||||
try:
|
||||
md.update(detail)
|
||||
except Exception:
|
||||
md = detail
|
||||
|
||||
# Best-effort: fetch synced lyric subtitles for MPV (LRC).
|
||||
try:
|
||||
track_id_for_lyrics = self._extract_track_id_from_result(result)
|
||||
except Exception:
|
||||
track_id_for_lyrics = None
|
||||
if track_id_for_lyrics and not md.get("_tidal_lyrics_subtitles"):
|
||||
lyr = self._fetch_track_lyrics(track_id_for_lyrics)
|
||||
if isinstance(lyr, dict) and lyr:
|
||||
try:
|
||||
md.setdefault("lyrics", lyr)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
subtitles = lyr.get("subtitles")
|
||||
if isinstance(subtitles, str) and subtitles.strip():
|
||||
md["_tidal_lyrics_subtitles"] = subtitles.strip()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Ensure downstream cmdlets see our enriched metadata.
|
||||
try:
|
||||
if isinstance(getattr(result, "full_metadata", None), dict):
|
||||
result.full_metadata.update(md)
|
||||
else:
|
||||
result.full_metadata = md
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
from cmdlet._shared import resolve_tidal_manifest_path
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
resolved = resolve_tidal_manifest_path({"full_metadata": md, "path": raw_path, "title": getattr(result, "title", "")})
|
||||
if not resolved:
|
||||
return None
|
||||
|
||||
resolved_text = str(resolved).strip()
|
||||
if not resolved_text:
|
||||
return None
|
||||
|
||||
track_id = self._extract_track_id_from_result(result)
|
||||
title_part = self._safe_filename(getattr(result, "title", None), fallback="hifi")
|
||||
hash_part = self._safe_filename(md.get("manifestHash"), fallback="")
|
||||
|
||||
stem_parts = ["hifi"]
|
||||
if track_id:
|
||||
stem_parts.append(str(track_id))
|
||||
if hash_part:
|
||||
stem_parts.append(hash_part[:12])
|
||||
if title_part:
|
||||
stem_parts.append(title_part)
|
||||
stem = "-".join([p for p in stem_parts if p])[:180].rstrip("- ")
|
||||
|
||||
codec = self._probe_audio_codec(resolved_text)
|
||||
suffix = self._preferred_audio_suffix(codec, md)
|
||||
|
||||
# If resolve_tidal_manifest_path returned a URL, prefer feeding it directly to ffmpeg.
|
||||
if resolved_text.lower().startswith("http"):
|
||||
out_file = output_dir / f"{stem}{suffix}"
|
||||
materialized = self._ffmpeg_demux_to_audio(input_ref=resolved_text, output_path=out_file)
|
||||
if materialized is not None:
|
||||
return materialized
|
||||
|
||||
# As a fallback, try downloading the URL directly if it looks like a file.
|
||||
try:
|
||||
import httpx
|
||||
|
||||
resp = httpx.get(resolved_text, timeout=float(getattr(self, "api_timeout", 10.0)))
|
||||
resp.raise_for_status()
|
||||
content = resp.content
|
||||
direct_path = output_dir / f"{stem}.bin"
|
||||
with open(direct_path, "wb") as fh:
|
||||
fh.write(content)
|
||||
return direct_path
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
try:
|
||||
source_path = Path(resolved_text)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
if source_path.is_file() and source_path.suffix.lower() == ".mpd":
|
||||
# Materialize audio from the local MPD.
|
||||
out_file = output_dir / f"{stem}{suffix}"
|
||||
materialized = self._ffmpeg_demux_to_audio(input_ref=str(source_path), output_path=out_file)
|
||||
if materialized is not None:
|
||||
return materialized
|
||||
return None
|
||||
|
||||
# If we somehow got a local audio file already, copy it to output_dir.
|
||||
if source_path.is_file() and source_path.suffix.lower() in {".m4a", ".mp3", ".flac", ".wav", ".mka", ".mp4"}:
|
||||
dest = output_dir / f"{stem}{source_path.suffix.lower()}"
|
||||
if self._has_nonempty_file(dest):
|
||||
return dest
|
||||
try:
|
||||
shutil.copyfile(source_path, dest)
|
||||
return dest
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
# As a last resort, attempt to treat the local path as an ffmpeg input.
|
||||
out_file = output_dir / f"{stem}{suffix}"
|
||||
materialized = self._ffmpeg_demux_to_audio(input_ref=resolved_text, output_path=out_file)
|
||||
return materialized
|
||||
|
||||
def _get_api_client_for_base(self, base_url: str) -> Optional[HifiApiClient]:
|
||||
base = base_url.rstrip("/")
|
||||
for client in self.api_clients:
|
||||
@@ -126,6 +506,8 @@ class HIFI(Provider):
|
||||
deduped: List[Dict[str, Any]] = []
|
||||
for item in items:
|
||||
track_id = item.get("id") or item.get("trackId")
|
||||
if track_id is None:
|
||||
continue
|
||||
try:
|
||||
track_int = int(track_id)
|
||||
except Exception:
|
||||
@@ -381,6 +763,29 @@ class HIFI(Provider):
|
||||
continue
|
||||
return None
|
||||
|
||||
def _fetch_track_lyrics(self, track_id: int) -> Optional[Dict[str, Any]]:
|
||||
if track_id <= 0:
|
||||
return None
|
||||
for base in self.api_urls:
|
||||
endpoint = f"{base.rstrip('/')}/lyrics/"
|
||||
try:
|
||||
client = self._get_api_client_for_base(base)
|
||||
payload = client.lyrics(track_id) if client else None
|
||||
if not isinstance(payload, dict):
|
||||
continue
|
||||
|
||||
lyrics_obj = payload.get("lyrics")
|
||||
if isinstance(lyrics_obj, dict) and lyrics_obj:
|
||||
return lyrics_obj
|
||||
|
||||
data_obj = payload.get("data")
|
||||
if isinstance(data_obj, dict) and data_obj:
|
||||
return data_obj
|
||||
except Exception as exc:
|
||||
debug(f"[hifi] Lyrics lookup failed for {endpoint}: {exc}")
|
||||
continue
|
||||
return None
|
||||
|
||||
def _build_track_columns(self, detail: Dict[str, Any], track_id: int) -> List[Tuple[str, str]]:
|
||||
values: List[Tuple[str, str]] = [
|
||||
("Track ID", str(track_id)),
|
||||
@@ -405,6 +810,27 @@ class HIFI(Provider):
|
||||
if not stage_is_last:
|
||||
return False
|
||||
|
||||
try:
|
||||
current_table = ctx.get_current_stage_table()
|
||||
except Exception:
|
||||
current_table = None
|
||||
table_type = (
|
||||
current_table.table
|
||||
if current_table and hasattr(current_table, "table")
|
||||
else None
|
||||
)
|
||||
if isinstance(table_type, str) and table_type.strip().lower() == "hifi.track":
|
||||
try:
|
||||
meta = (
|
||||
current_table.get_table_metadata()
|
||||
if current_table is not None and hasattr(current_table, "get_table_metadata")
|
||||
else {}
|
||||
)
|
||||
except Exception:
|
||||
meta = {}
|
||||
if isinstance(meta, dict) and meta.get("resolved_manifest"):
|
||||
return False
|
||||
|
||||
contexts = self._extract_track_selection_context(selected_items)
|
||||
if not contexts:
|
||||
return False
|
||||
@@ -426,6 +852,10 @@ class HIFI(Provider):
|
||||
|
||||
table = ResultTable("HIFI Track").set_preserve_order(True)
|
||||
table.set_table("hifi.track")
|
||||
try:
|
||||
table.set_table_metadata({"provider": "hifi", "view": "track", "resolved_manifest": True})
|
||||
except Exception:
|
||||
pass
|
||||
results_payload: List[Dict[str, Any]] = []
|
||||
for track_id, title, path, detail in track_details:
|
||||
# Decode the DASH MPD manifest to a local file and use it as the selectable/playable path.
|
||||
@@ -455,7 +885,7 @@ class HIFI(Provider):
|
||||
columns.insert(insert_pos, ("Album", album_title))
|
||||
|
||||
result = SearchResult(
|
||||
table="hifi.track",
|
||||
table="hifi",
|
||||
title=title,
|
||||
path=resolved_path,
|
||||
detail=f"id:{track_id}",
|
||||
|
||||
@@ -16,6 +16,10 @@ except ImportError: # pragma: no cover
|
||||
class Bandcamp(Provider):
|
||||
"""Search provider for Bandcamp."""
|
||||
|
||||
TABLE_AUTO_STAGES = {
|
||||
"bandcamp": ["download-file"],
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _base_url(raw_url: str) -> str:
|
||||
"""Normalize a Bandcamp URL down to scheme://netloc."""
|
||||
|
||||
@@ -27,8 +27,8 @@ def maybe_show_formats_table(
|
||||
|
||||
Returns an exit code when handled; otherwise None.
|
||||
"""
|
||||
if quiet_mode:
|
||||
return None
|
||||
# Do not suppress the picker in quiet/background mode: this selector UX is
|
||||
# required for Internet Archive "details" pages (which are not directly downloadable).
|
||||
|
||||
try:
|
||||
total_inputs = int(len(raw_urls or []) + len(piped_items or []))
|
||||
@@ -107,7 +107,7 @@ def maybe_show_formats_table(
|
||||
base_args.extend(["-path", str(out_arg)])
|
||||
|
||||
table = ResultTable(table_title).set_preserve_order(True)
|
||||
table.set_table("internetarchive.formats")
|
||||
table.set_table("internetarchive.format")
|
||||
table.set_source_command("download-file", base_args)
|
||||
|
||||
rows: List[Dict[str, Any]] = []
|
||||
@@ -474,6 +474,13 @@ class InternetArchive(Provider):
|
||||
"""
|
||||
URL = ("archive.org",)
|
||||
|
||||
TABLE_AUTO_STAGES = {
|
||||
"internetarchive": ["download-file"],
|
||||
"internetarchive.folder": ["download-file"],
|
||||
"internetarchive.format": ["download-file"],
|
||||
"internetarchive.formats": ["download-file"],
|
||||
}
|
||||
|
||||
def __init__(self, config: Optional[Dict[str, Any]] = None):
|
||||
super().__init__(config)
|
||||
conf = _pick_provider_config(self.config)
|
||||
@@ -577,7 +584,7 @@ class InternetArchive(Provider):
|
||||
path = f"https://archive.org/details/{identifier}"
|
||||
|
||||
sr = SearchResult(
|
||||
table="internetarchive",
|
||||
table="internetarchive.folder",
|
||||
title=title,
|
||||
path=path,
|
||||
detail=" · ".join(detail_parts),
|
||||
|
||||
@@ -656,6 +656,10 @@ def _libgen_metadata_to_tags(meta: Dict[str, Any]) -> List[str]:
|
||||
|
||||
|
||||
class Libgen(Provider):
|
||||
|
||||
TABLE_AUTO_STAGES = {
|
||||
"libgen": ["download-file"],
|
||||
}
|
||||
# Domains that should be routed to this provider when the user supplies a URL.
|
||||
# (Used by ProviderCore.registry.match_provider_name_for_url)
|
||||
URL_DOMAINS = (
|
||||
|
||||
@@ -214,10 +214,15 @@ def _archive_id_from_url(url: str) -> str:
|
||||
# - /details/<id>/...
|
||||
# - /borrow/<id>
|
||||
# - /download/<id>/...
|
||||
if len(parts) >= 2 and parts[0].lower() in {"details",
|
||||
"borrow",
|
||||
"download",
|
||||
"stream"}:
|
||||
# - /stream/<id>/...
|
||||
# - /metadata/<id>
|
||||
if len(parts) >= 2 and parts[0].lower() in {
|
||||
"details",
|
||||
"borrow",
|
||||
"download",
|
||||
"stream",
|
||||
"metadata",
|
||||
}:
|
||||
return str(parts[1]).strip()
|
||||
|
||||
# Sometimes the identifier is the first segment.
|
||||
@@ -225,37 +230,38 @@ def _archive_id_from_url(url: str) -> str:
|
||||
first = str(parts[0]).strip()
|
||||
if first and first.lower() not in {"account",
|
||||
"services",
|
||||
"metadata",
|
||||
"search",
|
||||
"advancedsearch.php"}:
|
||||
return first
|
||||
|
||||
|
||||
def edition_id_from_url(u: str) -> str:
|
||||
"""Extract an OpenLibrary edition id (OL...M) from a book URL."""
|
||||
try:
|
||||
p = urlparse(str(u))
|
||||
parts = [x for x in (p.path or "").split("/") if x]
|
||||
except Exception:
|
||||
parts = []
|
||||
if len(parts) >= 2 and str(parts[0]).lower() == "books":
|
||||
return str(parts[1]).strip()
|
||||
return ""
|
||||
|
||||
|
||||
def title_hint_from_url_slug(u: str) -> str:
|
||||
"""Derive a human-friendly title hint from the URL slug."""
|
||||
try:
|
||||
p = urlparse(str(u))
|
||||
parts = [x for x in (p.path or "").split("/") if x]
|
||||
slug = parts[-1] if parts else ""
|
||||
except Exception:
|
||||
slug = ""
|
||||
slug = (slug or "").strip().replace("_", " ")
|
||||
return slug or "OpenLibrary"
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def edition_id_from_url(u: str) -> str:
|
||||
"""Extract an OpenLibrary edition id (OL...M) from a book URL."""
|
||||
try:
|
||||
p = urlparse(str(u))
|
||||
parts = [x for x in (p.path or "").split("/") if x]
|
||||
except Exception:
|
||||
parts = []
|
||||
if len(parts) >= 2 and str(parts[0]).lower() == "books":
|
||||
return str(parts[1]).strip()
|
||||
return ""
|
||||
|
||||
|
||||
def title_hint_from_url_slug(u: str) -> str:
|
||||
"""Derive a human-friendly title hint from the URL slug."""
|
||||
try:
|
||||
p = urlparse(str(u))
|
||||
parts = [x for x in (p.path or "").split("/") if x]
|
||||
slug = parts[-1] if parts else ""
|
||||
except Exception:
|
||||
slug = ""
|
||||
slug = (slug or "").strip().replace("_", " ")
|
||||
return slug or "OpenLibrary"
|
||||
|
||||
|
||||
def _coerce_archive_field_list(value: Any) -> List[str]:
|
||||
"""Coerce an Archive.org metadata field to a list of strings."""
|
||||
if value is None:
|
||||
@@ -433,6 +439,22 @@ def _fetch_archive_item_metadata(archive_id: str,
|
||||
|
||||
|
||||
class OpenLibrary(Provider):
|
||||
|
||||
TABLE_AUTO_STAGES = {
|
||||
"openlibrary": ["download-file"],
|
||||
}
|
||||
|
||||
REQUIRED_CONFIG_KEYS = (
|
||||
"email",
|
||||
"password",
|
||||
)
|
||||
|
||||
DEFAULT_ARCHIVE_SCALE = 4
|
||||
QUALITY_TO_ARCHIVE_SCALE = {
|
||||
"high": 2,
|
||||
"medium": 5,
|
||||
"low": 8,
|
||||
}
|
||||
# Domains that should be routed to this provider when the user supplies a URL.
|
||||
# (Used by ProviderCore.registry.match_provider_name_for_url)
|
||||
URL_DOMAINS = (
|
||||
@@ -449,6 +471,41 @@ class OpenLibrary(Provider):
|
||||
class BookNotAvailableError(Exception):
|
||||
"""Raised when a book is not available for borrowing (waitlisted/in use)."""
|
||||
|
||||
def search_result_from_url(self, url: str) -> Optional[SearchResult]:
|
||||
"""Build a minimal SearchResult from a bare OpenLibrary/Archive URL."""
|
||||
edition_id = edition_id_from_url(url)
|
||||
title_hint = title_hint_from_url_slug(url)
|
||||
return SearchResult(
|
||||
table="openlibrary",
|
||||
title=title_hint,
|
||||
path=str(url),
|
||||
media_kind="book",
|
||||
full_metadata={"openlibrary_id": edition_id} if edition_id else {},
|
||||
)
|
||||
|
||||
def download_url(
|
||||
self,
|
||||
url: str,
|
||||
output_dir: Path,
|
||||
progress_callback: Optional[Callable[[str, int, Optional[int], str], None]] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Download a book directly from an OpenLibrary/Archive URL.
|
||||
|
||||
Returns a dict with the downloaded path and SearchResult when successful.
|
||||
"""
|
||||
sr = self.search_result_from_url(url)
|
||||
if sr is None:
|
||||
return None
|
||||
|
||||
downloaded = self.download(sr, output_dir, progress_callback)
|
||||
if not downloaded:
|
||||
return None
|
||||
|
||||
return {
|
||||
"path": Path(downloaded),
|
||||
"search_result": sr,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _credential_archive(config: Dict[str,
|
||||
Any]) -> Tuple[Optional[str],
|
||||
@@ -491,6 +548,57 @@ class OpenLibrary(Provider):
|
||||
str(password) if password is not None else None
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _archive_scale_from_config(cls, config: Dict[str, Any]) -> int:
|
||||
"""Resolve Archive.org book-reader scale from provider config.
|
||||
|
||||
Config:
|
||||
[provider=OpenLibrary]
|
||||
quality="medium" # High=2, Medium=5, Low=8
|
||||
|
||||
Default when missing/invalid: 4.
|
||||
"""
|
||||
|
||||
default_scale = int(getattr(cls, "DEFAULT_ARCHIVE_SCALE", 4) or 4)
|
||||
if not isinstance(config, dict):
|
||||
return default_scale
|
||||
|
||||
provider_config = config.get("provider", {})
|
||||
openlibrary_config = None
|
||||
if isinstance(provider_config, dict):
|
||||
openlibrary_config = provider_config.get("openlibrary")
|
||||
if not isinstance(openlibrary_config, dict):
|
||||
openlibrary_config = {}
|
||||
|
||||
raw_quality = openlibrary_config.get("quality")
|
||||
if raw_quality is None:
|
||||
return default_scale
|
||||
|
||||
if isinstance(raw_quality, (int, float)):
|
||||
try:
|
||||
val = int(raw_quality)
|
||||
except Exception:
|
||||
return default_scale
|
||||
return val if val > 0 else default_scale
|
||||
|
||||
try:
|
||||
q = str(raw_quality).strip().lower()
|
||||
except Exception:
|
||||
return default_scale
|
||||
if not q:
|
||||
return default_scale
|
||||
|
||||
mapped = cls.QUALITY_TO_ARCHIVE_SCALE.get(q)
|
||||
if isinstance(mapped, int) and mapped > 0:
|
||||
return mapped
|
||||
|
||||
# Allow numeric strings (e.g. quality="4").
|
||||
try:
|
||||
val = int(q)
|
||||
except Exception:
|
||||
return default_scale
|
||||
return val if val > 0 else default_scale
|
||||
|
||||
@staticmethod
|
||||
def _archive_error_body(response: requests.Response) -> str:
|
||||
try:
|
||||
@@ -1444,64 +1552,6 @@ class OpenLibrary(Provider):
|
||||
log("[openlibrary] Direct download failed", file=sys.stderr)
|
||||
return None
|
||||
|
||||
# --- Convenience helpers for URL-driven downloads (used by download-file) ---
|
||||
|
||||
def search_result_from_url(self, url: str) -> Optional[SearchResult]:
|
||||
"""Build a minimal SearchResult from a bare OpenLibrary URL."""
|
||||
edition_id = edition_id_from_url(url)
|
||||
title_hint = title_hint_from_url_slug(url)
|
||||
return SearchResult(
|
||||
table="openlibrary",
|
||||
title=title_hint,
|
||||
path=str(url),
|
||||
media_kind="book",
|
||||
full_metadata={"openlibrary_id": edition_id} if edition_id else {},
|
||||
)
|
||||
|
||||
def download_url(
|
||||
self,
|
||||
url: str,
|
||||
output_dir: Path,
|
||||
progress_callback: Optional[Callable[[str, int, Optional[int], str], None]] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Download a book directly from an OpenLibrary URL.
|
||||
|
||||
Returns a dict with the downloaded path and SearchResult when successful.
|
||||
"""
|
||||
sr = self.search_result_from_url(url)
|
||||
if sr is None:
|
||||
return None
|
||||
|
||||
downloaded = self.download(sr, output_dir, progress_callback)
|
||||
if not downloaded:
|
||||
return None
|
||||
|
||||
return {
|
||||
"path": Path(downloaded),
|
||||
"search_result": sr,
|
||||
}
|
||||
try:
|
||||
if progress_callback is not None:
|
||||
progress_callback("step", 0, None, "direct download")
|
||||
except Exception:
|
||||
pass
|
||||
out_path = unique_path(output_dir / f"{safe_title}.pdf")
|
||||
ok = download_file(
|
||||
pdf_url,
|
||||
out_path,
|
||||
session=self._session,
|
||||
progress_callback=(
|
||||
(
|
||||
lambda downloaded, total, label:
|
||||
progress_callback("bytes", downloaded, total, label)
|
||||
) if progress_callback is not None else None
|
||||
),
|
||||
)
|
||||
if ok:
|
||||
return out_path
|
||||
log("[openlibrary] Direct download failed", file=sys.stderr)
|
||||
return None
|
||||
|
||||
# 2) Borrow flow (credentials required).
|
||||
try:
|
||||
email, password = self._credential_archive(self.config or {})
|
||||
@@ -1510,6 +1560,15 @@ class OpenLibrary(Provider):
|
||||
"[openlibrary] Archive credentials missing; cannot borrow",
|
||||
file=sys.stderr
|
||||
)
|
||||
try:
|
||||
from SYS.rich_display import show_provider_config_panel
|
||||
|
||||
show_provider_config_panel(
|
||||
"openlibrary",
|
||||
keys=self.required_config_keys(),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
lendable = True
|
||||
@@ -1590,7 +1649,7 @@ class OpenLibrary(Provider):
|
||||
n_threads=10,
|
||||
directory=temp_dir,
|
||||
links=links,
|
||||
scale=3,
|
||||
scale=self._archive_scale_from_config(self.config or {}),
|
||||
book_id=archive_id,
|
||||
progress_callback=(
|
||||
(
|
||||
|
||||
@@ -29,6 +29,11 @@ def _get_podcastindex_credentials(config: Dict[str, Any]) -> Tuple[str, str]:
|
||||
class PodcastIndex(Provider):
|
||||
"""Search provider for PodcastIndex.org."""
|
||||
|
||||
TABLE_AUTO_STAGES = {
|
||||
"podcastindex": ["download-file"],
|
||||
"podcastindex.episodes": ["download-file"],
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _format_duration(value: Any) -> str:
|
||||
def _to_seconds(v: Any) -> Optional[int]:
|
||||
|
||||
@@ -204,6 +204,10 @@ def _suppress_aioslsk_noise() -> Any:
|
||||
|
||||
|
||||
class Soulseek(Provider):
|
||||
|
||||
TABLE_AUTO_STAGES = {
|
||||
"soulseek": ["download-file"],
|
||||
}
|
||||
"""Search provider for Soulseek P2P network."""
|
||||
|
||||
MUSIC_EXTENSIONS = {
|
||||
|
||||
@@ -10,6 +10,12 @@ from SYS.logger import log
|
||||
class YouTube(Provider):
|
||||
"""Search provider for YouTube using the yt_dlp Python package."""
|
||||
|
||||
TABLE_AUTO_STAGES = {
|
||||
"youtube": ["download-file"],
|
||||
}
|
||||
# If the user provides extra args on the selection stage, forward them to download-file.
|
||||
AUTO_STAGE_USE_SELECTION_ARGS = True
|
||||
|
||||
def search(
|
||||
self,
|
||||
query: str,
|
||||
|
||||
Reference in New Issue
Block a user