dfdf
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from urllib.parse import urlparse
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from ProviderCore.base import Provider, SearchResult
|
||||
@@ -15,6 +16,204 @@ except ImportError: # pragma: no cover
|
||||
class Bandcamp(Provider):
|
||||
"""Search provider for Bandcamp."""
|
||||
|
||||
@staticmethod
|
||||
def _base_url(raw_url: str) -> str:
|
||||
"""Normalize a Bandcamp URL down to scheme://netloc."""
|
||||
text = str(raw_url or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
try:
|
||||
parsed = urlparse(text)
|
||||
if not parsed.scheme or not parsed.netloc:
|
||||
return text
|
||||
return f"{parsed.scheme}://{parsed.netloc}"
|
||||
except Exception:
|
||||
return text
|
||||
|
||||
@classmethod
|
||||
def _discography_url(cls, raw_url: str) -> str:
|
||||
base = cls._base_url(raw_url)
|
||||
if not base:
|
||||
return ""
|
||||
# Bandcamp discography lives under /music.
|
||||
return base.rstrip("/") + "/music"
|
||||
|
||||
def _scrape_artist_page(self, page: Any, artist_url: str, limit: int = 50) -> List[SearchResult]:
|
||||
"""Scrape an artist page for albums/tracks (discography)."""
|
||||
base = self._base_url(artist_url)
|
||||
discography_url = self._discography_url(artist_url)
|
||||
if not base or not discography_url:
|
||||
return []
|
||||
|
||||
debug(f"[bandcamp] Scraping artist page: {discography_url}")
|
||||
page.goto(discography_url)
|
||||
page.wait_for_load_state("domcontentloaded")
|
||||
|
||||
results: List[SearchResult] = []
|
||||
cards = page.query_selector_all("li.music-grid-item") or []
|
||||
if not cards:
|
||||
# Fallback selector
|
||||
cards = page.query_selector_all(".music-grid-item") or []
|
||||
|
||||
for item in cards[:limit]:
|
||||
try:
|
||||
link = item.query_selector("a")
|
||||
if not link:
|
||||
continue
|
||||
|
||||
href = link.get_attribute("href") or ""
|
||||
href = str(href).strip()
|
||||
if not href:
|
||||
continue
|
||||
|
||||
if href.startswith("/"):
|
||||
target = base.rstrip("/") + href
|
||||
elif href.startswith("http://") or href.startswith("https://"):
|
||||
target = href
|
||||
else:
|
||||
target = base.rstrip("/") + "/" + href
|
||||
|
||||
title_node = item.query_selector("p.title") or item.query_selector(".title")
|
||||
title = (title_node.inner_text().strip() if title_node else "")
|
||||
if title:
|
||||
title = " ".join(title.split())
|
||||
if not title:
|
||||
title = target.rsplit("/", 1)[-1]
|
||||
|
||||
kind = "album" if "/album/" in target else ("track" if "/track/" in target else "item")
|
||||
|
||||
results.append(
|
||||
SearchResult(
|
||||
table="bandcamp",
|
||||
title=title,
|
||||
path=target,
|
||||
detail="",
|
||||
annotations=[kind],
|
||||
media_kind="audio",
|
||||
columns=[
|
||||
("Title", title),
|
||||
("Type", kind),
|
||||
("Url", target),
|
||||
],
|
||||
full_metadata={
|
||||
"type": kind,
|
||||
"url": target,
|
||||
"artist_url": base,
|
||||
},
|
||||
)
|
||||
)
|
||||
except Exception as exc:
|
||||
debug(f"[bandcamp] Error parsing artist item: {exc}")
|
||||
|
||||
return results
|
||||
|
||||
def selector(self, selected_items: List[Any], *, ctx: Any, stage_is_last: bool = True, **_kwargs: Any) -> bool:
|
||||
"""Handle Bandcamp `@N` selection.
|
||||
|
||||
If the selected item is an ARTIST result, selecting it auto-expands into
|
||||
a discography table by scraping the artist URL.
|
||||
"""
|
||||
if not stage_is_last:
|
||||
return False
|
||||
|
||||
if sync_playwright is None:
|
||||
return False
|
||||
|
||||
# Only handle artist selections.
|
||||
chosen: List[Dict[str, Any]] = []
|
||||
for item in selected_items or []:
|
||||
payload: Dict[str, Any] = {}
|
||||
if isinstance(item, dict):
|
||||
payload = item
|
||||
else:
|
||||
try:
|
||||
if hasattr(item, "to_dict"):
|
||||
payload = item.to_dict() # type: ignore[assignment]
|
||||
except Exception:
|
||||
payload = {}
|
||||
if not payload:
|
||||
try:
|
||||
payload = {
|
||||
"title": getattr(item, "title", None),
|
||||
"url": getattr(item, "url", None),
|
||||
"path": getattr(item, "path", None),
|
||||
"metadata": getattr(item, "metadata", None),
|
||||
"extra": getattr(item, "extra", None),
|
||||
}
|
||||
except Exception:
|
||||
payload = {}
|
||||
|
||||
meta = payload.get("metadata") or payload.get("full_metadata") or {}
|
||||
if not isinstance(meta, dict):
|
||||
meta = {}
|
||||
extra = payload.get("extra")
|
||||
if isinstance(extra, dict):
|
||||
meta = {**meta, **extra}
|
||||
|
||||
type_val = str(meta.get("type") or "").strip().lower()
|
||||
if type_val != "artist":
|
||||
continue
|
||||
|
||||
title = str(payload.get("title") or "").strip()
|
||||
url_val = str(payload.get("url") or payload.get("path") or meta.get("url") or "").strip()
|
||||
base = self._base_url(url_val)
|
||||
if not base:
|
||||
continue
|
||||
|
||||
chosen.append({"title": title, "url": base, "location": str(meta.get("artist") or "").strip()})
|
||||
|
||||
if not chosen:
|
||||
return False
|
||||
|
||||
# Build a new table from artist discography.
|
||||
try:
|
||||
from result_table import ResultTable
|
||||
from rich_display import stdout_console
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
artist_title = chosen[0].get("title") or "artist"
|
||||
artist_url = chosen[0].get("url") or ""
|
||||
|
||||
try:
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
page = browser.new_page()
|
||||
discography = self._scrape_artist_page(page, artist_url, limit=50)
|
||||
browser.close()
|
||||
except Exception as exc:
|
||||
print(f"bandcamp artist lookup failed: {exc}\n")
|
||||
return True
|
||||
|
||||
table = ResultTable(f"Bandcamp: artist:{artist_title}").set_preserve_order(True)
|
||||
table.set_table("bandcamp")
|
||||
try:
|
||||
table.set_value_case("lower")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
results_payload: List[Dict[str, Any]] = []
|
||||
for r in discography:
|
||||
table.add_result(r)
|
||||
try:
|
||||
results_payload.append(r.to_dict())
|
||||
except Exception:
|
||||
results_payload.append({"table": "bandcamp", "title": getattr(r, "title", ""), "path": getattr(r, "path", "")})
|
||||
|
||||
try:
|
||||
ctx.set_last_result_table(table, results_payload)
|
||||
ctx.set_current_stage_table(table)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
stdout_console().print()
|
||||
stdout_console().print(table)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return True
|
||||
|
||||
def search(
|
||||
self,
|
||||
query: str,
|
||||
@@ -73,6 +272,7 @@ class Bandcamp(Provider):
|
||||
|
||||
title = link.inner_text().strip()
|
||||
target_url = link.get_attribute("href")
|
||||
base_url = self._base_url(str(target_url or ""))
|
||||
|
||||
subhead = item.query_selector(".subhead")
|
||||
artist = subhead.inner_text().strip() if subhead else "Unknown"
|
||||
@@ -89,13 +289,15 @@ class Bandcamp(Provider):
|
||||
annotations=[media_type],
|
||||
media_kind="audio",
|
||||
columns=[
|
||||
("Name", title),
|
||||
("Artist", artist),
|
||||
("Title", title),
|
||||
("Location", artist),
|
||||
("Type", media_type),
|
||||
("Url", base_url or str(target_url or "")),
|
||||
],
|
||||
full_metadata={
|
||||
"artist": artist,
|
||||
"type": media_type,
|
||||
"url": base_url or str(target_url or ""),
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
@@ -175,42 +175,11 @@ class Libgen(Provider):
|
||||
elapsed = max(0.001, now - start_time)
|
||||
speed = downloaded / elapsed
|
||||
|
||||
eta_seconds = 0.0
|
||||
if total and total > 0 and speed > 0:
|
||||
eta_seconds = max(0.0, float(total - downloaded) / float(speed))
|
||||
minutes, seconds = divmod(int(eta_seconds), 60)
|
||||
hours, minutes = divmod(minutes, 60)
|
||||
eta_str = f"{hours:02d}:{minutes:02d}:{seconds:02d}" if total else "?:?:?"
|
||||
speed_str = progress_bar.format_bytes(speed) + "/s"
|
||||
|
||||
percent_str = None
|
||||
if total and total > 0:
|
||||
percent = (downloaded / total) * 100.0
|
||||
percent_str = f"{percent:.1f}%"
|
||||
|
||||
line = progress_bar.format_progress(
|
||||
percent_str=percent_str,
|
||||
downloaded=downloaded,
|
||||
total=total,
|
||||
speed_str=speed_str,
|
||||
eta_str=eta_str,
|
||||
)
|
||||
|
||||
# Prefix with filename for clarity when downloading multiple items.
|
||||
if label:
|
||||
line = f"{label} {line}"
|
||||
|
||||
if getattr(sys.stderr, "isatty", lambda: True)():
|
||||
sys.stderr.write("\r" + line + " ")
|
||||
sys.stderr.flush()
|
||||
progress_bar.update(downloaded=downloaded, total=total, label=str(label or "download"), file=sys.stderr)
|
||||
last_progress_time[0] = now
|
||||
|
||||
ok, final_path = download_from_mirror(target, out_path, progress_callback=progress_callback)
|
||||
# Clear the in-place progress line.
|
||||
if getattr(sys.stderr, "isatty", lambda: True)():
|
||||
sys.stderr.write("\r" + (" " * 180) + "\r")
|
||||
sys.stderr.write("\n")
|
||||
sys.stderr.flush()
|
||||
progress_bar.finish()
|
||||
if ok and final_path:
|
||||
return Path(final_path)
|
||||
return None
|
||||
|
||||
@@ -584,48 +584,19 @@ async def download_soulseek_file(
|
||||
log(f"[soulseek] Download timeout after {timeout}s", file=sys.stderr)
|
||||
bytes_done = int(getattr(transfer, "bytes_transfered", 0) or 0)
|
||||
state_val = getattr(getattr(transfer, "state", None), "VALUE", None)
|
||||
try:
|
||||
if getattr(sys.stderr, "isatty", lambda: False)():
|
||||
sys.stderr.write("\r" + (" " * 140) + "\r")
|
||||
sys.stderr.flush()
|
||||
except Exception:
|
||||
pass
|
||||
progress_bar.finish()
|
||||
return None, state_val, bytes_done, elapsed
|
||||
|
||||
bytes_done = int(getattr(transfer, "bytes_transfered", 0) or 0)
|
||||
total_bytes = int(getattr(transfer, "filesize", 0) or 0)
|
||||
now = time.time()
|
||||
if now - last_progress_time >= 0.5:
|
||||
percent = (bytes_done / total_bytes) * 100.0 if total_bytes > 0 else 0.0
|
||||
speed = bytes_done / elapsed if elapsed > 0 else 0.0
|
||||
eta_str: Optional[str] = None
|
||||
if total_bytes > 0 and speed > 0:
|
||||
try:
|
||||
eta_seconds = max(0.0, float(total_bytes - bytes_done) / float(speed))
|
||||
minutes, seconds = divmod(int(eta_seconds), 60)
|
||||
hours, minutes = divmod(minutes, 60)
|
||||
eta_str = f"{hours:02d}:{minutes:02d}:{seconds:02d}"
|
||||
except Exception:
|
||||
eta_str = None
|
||||
|
||||
speed_str = progress_bar.format_bytes(speed) + "/s"
|
||||
progress_line = progress_bar.format_progress(
|
||||
percent_str=f"{percent:.1f}%",
|
||||
progress_bar.update(
|
||||
downloaded=bytes_done,
|
||||
total=total_bytes if total_bytes > 0 else None,
|
||||
speed_str=speed_str,
|
||||
eta_str=eta_str,
|
||||
label="download",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
try:
|
||||
if getattr(sys.stderr, "isatty", lambda: False)():
|
||||
sys.stderr.write("\r" + progress_line + " ")
|
||||
sys.stderr.flush()
|
||||
else:
|
||||
log(progress_line, file=sys.stderr)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
last_progress_time = now
|
||||
|
||||
await asyncio.sleep(1)
|
||||
@@ -635,12 +606,7 @@ async def download_soulseek_file(
|
||||
final_elapsed = time.time() - start_time
|
||||
|
||||
# Clear in-place progress bar.
|
||||
try:
|
||||
if getattr(sys.stderr, "isatty", lambda: False)():
|
||||
sys.stderr.write("\r" + (" " * 140) + "\r")
|
||||
sys.stderr.flush()
|
||||
except Exception:
|
||||
pass
|
||||
progress_bar.finish()
|
||||
|
||||
# If a file was written, treat it as success even if state is odd.
|
||||
try:
|
||||
|
||||
@@ -467,27 +467,16 @@ class Telegram(Provider):
|
||||
pass
|
||||
|
||||
# Progress callback: prints to stderr so it doesn't interfere with pipeline stdout.
|
||||
from models import ProgressBar
|
||||
progress_bar = ProgressBar()
|
||||
last_print = {"t": 0.0}
|
||||
def _progress(current: int, total: int) -> None:
|
||||
try:
|
||||
now = time.monotonic()
|
||||
# Throttle to avoid spamming.
|
||||
if now - float(last_print.get("t", 0.0)) < 0.25 and current < total:
|
||||
return
|
||||
last_print["t"] = now
|
||||
|
||||
pct = ""
|
||||
try:
|
||||
if total and total > 0:
|
||||
pct = f" {min(100.0, (current / total) * 100.0):5.1f}%"
|
||||
except Exception:
|
||||
pct = ""
|
||||
|
||||
line = f"[telegram] Downloading{pct} ({_format_bytes(current)}/{_format_bytes(total)})"
|
||||
sys.stderr.write("\r" + line)
|
||||
sys.stderr.flush()
|
||||
except Exception:
|
||||
now = time.monotonic()
|
||||
# Throttle to avoid spamming.
|
||||
if now - float(last_print.get("t", 0.0)) < 0.25 and current < total:
|
||||
return
|
||||
last_print["t"] = now
|
||||
progress_bar.update(downloaded=int(current), total=int(total), label="telegram", file=sys.stderr)
|
||||
|
||||
part_kb = self._resolve_part_size_kb(file_size)
|
||||
try:
|
||||
@@ -502,11 +491,7 @@ class Telegram(Provider):
|
||||
except TypeError:
|
||||
# Older/newer Telethon versions may not accept part_size_kb on download_media.
|
||||
downloaded = _resolve(client.download_media(message, file=str(output_dir), progress_callback=_progress))
|
||||
try:
|
||||
sys.stderr.write("\n")
|
||||
sys.stderr.flush()
|
||||
except Exception:
|
||||
pass
|
||||
progress_bar.finish()
|
||||
if not downloaded:
|
||||
raise Exception("Telegram download returned no file")
|
||||
downloaded_path = Path(str(downloaded))
|
||||
|
||||
Reference in New Issue
Block a user