feat(sources): add Libgen as a direct catalogue search source (#1326)

## What

Adds **Libgen as a search source**. Today Libgen is only a download
mirror (reached by an Anna's Archive md5), so anything in Libgen but not
in AA's search index is invisible — and that's where most of the CBZ/CBR
comics and manga live. A Libgen search for *One Piece*, for instance,
turns up ~99 volumes that AA search never shows.

It's a self-contained `release_sources/libgen/` package (source +
handler + settings) plus one line to register it. **No changes to
`direct_download.py`** — it reuses the existing `ads.php → get.php`
resolution and the mirrors already configured in `LIBGEN_MIRROR_URLS`.
Plain HTTP, no bypasser needed (libgen.li isn't behind DDoS-Guard).
Opt-in via a settings toggle.

## Worth a look in review

- **`source_id` is `libgen:<md5>`, not the bare md5.** The download
queue keys on `task_id` (= `source_id`), and `direct_download` already
uses the bare md5. Since AA indexes a lot of Libgen, the same md5 shows
up from both sources — a bare id would collide in the queue. The handler
strips the prefix before downloading.
- **Reachable like the other non-default sources** (Prowlarr,
AudiobookBay, …): it appears in the per-book release search, not the
free-text box (that stays wired to `direct_download`).

Tests in `tests/libgen/` cover parsing (both row layouts), the source,
the handler, and `get_record`. Lint/format/typecheck clean.
This commit is contained in:
Alex Guerrieri
2026-09-14 01:08:08 -04:00
committed by GitHub
parent 1b17fe179a
commit af21d1da1f
12 changed files with 1091 additions and 0 deletions
+1
View File
@@ -431,6 +431,7 @@ _BUILTIN_SOURCE_MODULES = (
"shelfmark.release_sources.audiobookbay",
"shelfmark.release_sources.direct_download",
"shelfmark.release_sources.irc",
"shelfmark.release_sources.libgen",
"shelfmark.release_sources.newznab",
"shelfmark.release_sources.prowlarr",
)
@@ -0,0 +1,6 @@
"""Libgen release source - direct catalogue search over the libgen.li family."""
# Import to trigger registration
from shelfmark.release_sources.libgen import handler as handler
from shelfmark.release_sources.libgen import settings as settings
from shelfmark.release_sources.libgen import source as source
+114
View File
@@ -0,0 +1,114 @@
"""Libgen download handler - resolves an md5 to a file via the ads.php cascade.
Selected by ``get_handler(task.source)`` for ``source == "libgen"``. It mirrors
DirectDownloadHandler's shape (stage into TMP_DIR, let the orchestrator post-process) but
only knows the libgen ``ads.php?md5= -> get.php`` path, keyed on the md5 the search source
put in ``source_id``.
"""
from typing import TYPE_CHECKING
from shelfmark.config.env import TMP_DIR
from shelfmark.core.config import config
from shelfmark.core.logger import setup_logger
from shelfmark.core.models import build_filename
from shelfmark.download import http as downloader
from shelfmark.release_sources import DownloadHandler, register_handler
from shelfmark.release_sources.libgen import scraper
if TYPE_CHECKING:
from collections.abc import Callable
from threading import Event
from shelfmark.core.models import DownloadTask
logger = setup_logger(__name__)
# Files under this size are almost certainly an error/challenge page, not a book. Same
# threshold direct_download uses; duplicated to keep the package self-contained.
_MIN_VALID_FILE_SIZE = 10 * 1024
@register_handler("libgen")
class LibgenHandler(DownloadHandler):
"""Download handler for Libgen search releases."""
def download(
self,
task: DownloadTask,
cancel_flag: Event,
progress_callback: Callable[[float], None],
status_callback: Callable[[str, str | None], None],
) -> str | None:
"""Resolve the md5 through each configured mirror and download the file.
Returns the staged temp path on success (orchestrator handles post-processing) or
None if every mirror fails.
"""
from shelfmark.core import mirrors
try:
if cancel_flag.is_set():
status_callback("cancelled", "Cancelled")
return None
# source_id was namespaced "libgen:<md5>" to avoid a queue-key collision with
# direct_download; strip it back to the bare (lowercase) md5 the download page expects.
md5 = task.task_id.split(":", 1)[-1].lower()
if config.get("FILE_ORGANIZATION", "rename") == "none":
book_name = f"{md5}.{task.format or 'bin'}"
else:
book_name = build_filename(task.title, task.author, task.year, task.format)
book_path = TMP_DIR / book_name
for base in mirrors.get_libgen_mirrors():
if cancel_flag.is_set():
status_callback("cancelled", "Cancelled")
return None
ads_url = f"{base.rstrip('/')}/ads.php?md5={md5}"
status_callback("resolving", "Resolving Libgen")
ads_html = scraper.fetch_page(ads_url, (5, 10))
if not ads_html:
continue
get_url = scraper.resolve_download_url(ads_html, base)
if not get_url:
continue
# _selector=None: download_url builds its own AAMirrorSelector (a no-op for
# non-AA URLs), so we avoid initialising dead AA-mirror state here.
data = downloader.download_url(
get_url,
task.size or "",
progress_callback,
cancel_flag,
None,
status_callback,
referer=ads_url,
)
if not data:
continue
if data.tell() < _MIN_VALID_FILE_SIZE:
logger.warning("Libgen file too small from %s, treating as failure", base)
continue
data.seek(0)
with book_path.open("wb") as file:
file.write(data.getbuffer())
return str(book_path)
except Exception as exc:
if cancel_flag.is_set():
status_callback("cancelled", "Cancelled")
else:
logger.exception("Error downloading from Libgen")
status_callback("error", str(exc))
return None
else:
# Loop exhausted without returning: every mirror failed to resolve/download.
status_callback("error", "All Libgen mirrors failed")
return None
def cancel(self, task_id: str) -> bool:
"""Cancellation is handled by the orchestrator via the cancel_flag."""
return False
+252
View File
@@ -0,0 +1,252 @@
"""Libgen catalogue scraping: search results and download-link resolution.
This is the pure fetch+parse core of the Libgen source. It talks to the libgen.li
family of mirrors (``index.php?req=`` search, ``ads.php?md5=`` download pages) using
plain HTTP -- these mirrors are not behind DDoS-Guard, so no browser/bypasser is
needed. All shelfmark-stateful behaviour lives in source.py/handler.py.
"""
import re
from http import HTTPStatus
from urllib.parse import quote
import requests
from bs4 import BeautifulSoup, Tag
from shelfmark.core.languages import normalize_language
from shelfmark.core.logger import setup_logger
from shelfmark.download import http as downloader
from shelfmark.download import network
from shelfmark.release_sources import BrowseRecord
logger = setup_logger(__name__)
# The libgen.li results table. Both full (9-cell) and compact (5-cell) rows live in it.
_RESULTS_TABLE_ID = "tablelibgen"
# md5 appears in the row's Mirrors cell as get.php?md5=<hash> and an AA /md5/<hash> link.
_MD5_RE = re.compile(r"md5=([0-9a-f]{32})", re.IGNORECASE)
# Patterns for the keyed GET link on an ads.php page. Kept in sync with the resolution
# libgen download has always used (direct_download._LIBGEN_GET_PATTERNS); duplicated here
# on purpose so the Libgen source stays self-contained and does not import that module's
# internals (which an in-flight upstream refactor is relocating).
_GET_KEY_PATTERNS = [
re.compile(
r'<a\s+href=["\']([^"\']*get\.php\?md5=[^"\']+&key=[^"\']+)["\'][^>]*>\s*'
r"<h2[^>]*>GET</h2>\s*</a>",
re.IGNORECASE,
),
re.compile(
r'<a[^>]+href=["\']([^"\']*get\.php\?md5=[^"\']+&(?:amp;)?key=[^"\']+)["\']',
re.IGNORECASE,
),
re.compile(
r'<a\s+href=["\']([^"\']*get\.php[^"\']*)["\'][^>]*>[\s\S]*?<h2[^>]*>GET</h2>',
re.IGNORECASE,
),
re.compile(
r'href=["\']([^"\']*get\.php\?[^"\']*md5=[^"\']*&[^"\']*key=[^"\']+)["\']',
re.IGNORECASE,
),
]
# Labels that terminate a metadata value on an ads.php page, so e.g. "Year: 2003 ISBN: ..."
# stops Year at "ISBN:" rather than swallowing it. We only emit a subset (see _parse_ads_metadata).
_METADATA_STOP_LABELS = [
"Title",
"Series",
"Author(s)",
"Publisher",
"Year",
"Language",
"Pages",
"ISBN",
"Edition",
"Extension",
"Size",
"Time added",
"ID",
"Filename",
"Description",
]
def fetch_page(url: str, timeout: tuple[int, int] = (5, 15)) -> str | None:
"""GET a libgen page, returning its text on HTTP 200 or None on any failure.
Public (not underscore-prefixed) because handler.py fetches ads.php pages through it
and tests patch it. Uses the app's proxy/SSL/DNS configuration so egress stays on
whatever network the container is bound to (the VPN namespace, in the deployed stack).
"""
try:
response = requests.get(
url,
headers=downloader.DOWNLOAD_HEADERS,
timeout=timeout,
allow_redirects=True,
proxies=network.get_proxies(url),
verify=network.get_ssl_verify(url),
)
except requests.exceptions.RequestException as exc:
logger.debug("Libgen fetch failed for %s: %s", url, exc)
return None
if response.status_code != HTTPStatus.OK:
logger.debug("Libgen fetch %s returned %s", url, response.status_code)
return None
return response.text
def search_libgen(
query: str,
mirrors: list[str],
*,
max_results: int,
timeout: tuple[int, int] = (5, 15),
) -> list[BrowseRecord]:
"""Search each mirror's catalogue until one answers with a results table.
The first mirror that returns a parseable ``#tablelibgen`` wins -- including when that
table is empty ([] is returned as final). Mirrors can lag independently, but falling
through on every empty result would multiply latency under the shared search deadline,
so an empty-but-well-formed answer is trusted rather than re-queried elsewhere.
"""
for base in mirrors:
url = f"{base.rstrip('/')}/index.php?req={quote(query)}&res={max_results}"
html = fetch_page(url, timeout)
if html is None:
continue
records = _parse_results(html, base)
if records is not None:
return records
return []
def fetch_record_by_md5(
md5: str,
mirrors: list[str],
*,
timeout: tuple[int, int] = (5, 10),
) -> BrowseRecord | None:
"""Resolve a single record from its md5 by parsing an ads.php page's metadata.
libgen's ``index.php?req=<md5>`` does not match on md5 (req= indexes title/author/
description), so md5 -> record must go through the ads.php page instead.
"""
for base in mirrors:
html = fetch_page(f"{base.rstrip('/')}/ads.php?md5={md5}", timeout)
if html is None:
continue
record = _parse_ads_metadata(html, md5, base)
if record is not None:
return record
return None
def resolve_download_url(ads_html: str, base_url: str) -> str | None:
"""Extract the keyed get.php download URL from an ads.php page, or None."""
if "get.php" not in ads_html:
return None
for pattern in _GET_KEY_PATTERNS:
match = pattern.search(ads_html)
if not match:
continue
url = match.group(1).replace("&amp;", "&").replace("&gt;", ">").replace("&lt;", "<")
if not url.startswith("http"):
url = f"{base_url.rstrip('/')}/{url.lstrip('/')}"
return url
return None
def _cell_text(cell: Tag) -> str:
"""Cell text with runs of whitespace (incl. &nbsp; / \\xa0) collapsed to single spaces."""
return re.sub(r"\s+", " ", cell.get_text(" ", strip=True)).strip()
def _parse_results(html: str, base_url: str) -> list[BrowseRecord] | None:
"""Parse a libgen search page.
Returns None when the page has no results table (a challenge/error page -> the caller
tries the next mirror), or a list (possibly empty) when the table is present.
Row shapes vary and carry NO rowspans: full rows have 9 cells
``[Title, Author, Publisher, Year, Language, Pages, Size, Ext, Mirrors]`` and compact
rows (extra files under one edition) have 5 ``[Title, Pages, Size, Ext, Mirrors]``. The
file-level columns are stable from the right, so index from the end: Mirrors[-1] (md5),
Ext[-2], Size[-3]. Title is always [0]. Author/Language exist only on full rows.
The two shapes are the only ones libgen.li is known to emit; an unexpected width just
fails safe (author/language read as None) rather than mis-columning.
"""
soup = BeautifulSoup(html, "html.parser")
table = soup.find("table", id=_RESULTS_TABLE_ID)
if not isinstance(table, Tag):
return None
records: list[BrowseRecord] = []
for row in table.find_all("tr")[1:]: # skip the header row
cells = row.find_all("td")
if len(cells) < 5:
continue
# Scope the md5 to the Mirrors cell (last column): scanning the whole row could match
# an md5-shaped string elsewhere (e.g. a cover-image URL) and misattribute it.
md5_match = _MD5_RE.search(str(cells[-1]))
if not md5_match:
continue # spacer/section rows carry no md5
md5 = md5_match.group(1).lower()
title = _cell_text(cells[0])
fmt = _cell_text(cells[-2]).lower() or None
size = _cell_text(cells[-3]) or None
author = None
language = None
if len(cells) >= 9: # full row: middle metadata columns are present
author = _cell_text(cells[1]) or None
language = normalize_language(_cell_text(cells[4]))
records.append(
BrowseRecord(
id=md5,
title=title,
source="libgen",
author=author,
language=language,
size=size,
format=fmt,
source_url=f"{base_url.rstrip('/')}/ads.php?md5={md5}",
)
)
return records
def _parse_ads_metadata(html: str, md5: str, base_url: str) -> BrowseRecord | None:
"""Build a BrowseRecord from an ads.php page's labelled metadata.
The page's metadata lives in a deeply nested table, so read it from the visible text by
label rather than by cell position -- the labels (Title:, Series:, Author(s): ...) are
stable even though the surrounding markup is not. Returns None if the page has no title.
"""
text = re.sub(r"\s+", " ", BeautifulSoup(html, "html.parser").get_text(" ", strip=True))
def field(name: str) -> str | None:
others = "|".join(
re.escape(other) + r":" for other in _METADATA_STOP_LABELS if other != name
)
match = re.search(re.escape(name) + r":\s*(.*?)\s*(?:" + others + r"|$)", text)
value = match.group(1).strip() if match else ""
return value or None
title = field("Title")
if not title:
return None
return BrowseRecord(
id=md5,
title=title,
source="libgen",
author=field("Author(s)"),
publisher=field("Publisher"),
year=field("Year"),
language=normalize_language(field("Language") or ""),
source_url=f"{base_url.rstrip('/')}/ads.php?md5={md5}",
)
@@ -0,0 +1,34 @@
"""Libgen search settings registration."""
from shelfmark.core.settings_registry import (
CheckboxField,
NumberField,
SettingsField,
register_settings,
)
@register_settings("libgen_config", "Libgen Search", icon="download", order=46)
def libgen_config_settings() -> list[SettingsField]:
"""Libgen search configuration settings."""
return [
CheckboxField(
key="LIBGEN_SEARCH_ENABLED",
label="Enable Libgen Search",
description=(
"Search the Libgen catalogue directly, including CBZ/CBR comics and manga "
"that Anna's Archive does not index. Uses the Libgen mirrors configured "
"under Mirrors for both search and download."
),
default=False,
),
NumberField(
key="LIBGEN_SEARCH_MAX_RESULTS",
label="Max Results",
description="Maximum number of results to request per search (1-100).",
default=25,
min_value=1,
max_value=100,
show_when={"field": "LIBGEN_SEARCH_ENABLED", "value": True},
),
]
+198
View File
@@ -0,0 +1,198 @@
"""Libgen release source - searches the libgen catalogue directly.
Anna's Archive is shelfmark's only other web search source, and libgen appears there
purely as a download mirror keyed by an AA md5. This source searches libgen's own
catalogue, which surfaces content AA does not index -- most visibly CBZ/CBR comics and
manga volumes. Downloads reuse the existing ``ads.php?md5=`` resolution (see handler.py).
"""
from typing import TYPE_CHECKING, ClassVar
from shelfmark.core import mirrors
from shelfmark.core.config import config
from shelfmark.core.logger import setup_logger
from shelfmark.release_sources import (
BrowseRecord,
ColumnAlign,
ColumnColorHint,
ColumnRenderType,
ColumnSchema,
Release,
ReleaseColumnConfig,
ReleaseProtocol,
ReleaseSource,
register_source,
)
from shelfmark.release_sources.libgen import scraper
if TYPE_CHECKING:
from shelfmark.core.models import DownloadTask # noqa: F401
from shelfmark.core.search_plan import ReleaseSearchPlan
from shelfmark.metadata_providers import BookMetadata
logger = setup_logger(__name__)
_DEFAULT_MAX_RESULTS = 25
def _coerce_positive_int(value: object, default: int) -> int:
"""Return a positive integer config value or the provided default."""
if isinstance(value, bool):
return default
if isinstance(value, int) and value > 0:
return value
return default
def _build_query_candidates(plan: ReleaseSearchPlan, book: BookMetadata) -> list[str]:
"""Build ordered, de-duplicated search queries from the plan (mirrors AudiobookBay)."""
candidates: list[str] = []
if plan.manual_query:
candidates.append(plan.manual_query.strip())
elif plan.title_variants:
variant = plan.title_variants[0]
combined = f"{variant.title} {variant.author}".strip()
title_only = (variant.title or "").strip()
if combined:
candidates.append(combined)
if title_only and title_only.lower() != combined.lower():
candidates.append(title_only)
elif book.title:
candidates.append(book.title.strip())
deduped: list[str] = []
seen: set[str] = set()
for candidate in candidates:
normalized = candidate.strip()
if not normalized or normalized.lower() in seen:
continue
seen.add(normalized.lower())
deduped.append(normalized)
return deduped
@register_source("libgen")
class LibgenSource(ReleaseSource):
"""Release source that searches the libgen catalogue for downloadable files."""
name = "libgen"
display_name = "Libgen"
supported_content_types: ClassVar[list[str]] = ["ebook"] # incl. comics/manga (cbz/cbr)
def is_available(self) -> bool:
"""Available only when explicitly enabled and libgen mirrors are configured.
``is True`` rather than ``bool(...)`` matches the AudiobookBay idiom and avoids a
truthy string ever enabling network egress to an unmoderated site.
"""
return (
config.get("LIBGEN_SEARCH_ENABLED", False) is True
and mirrors.has_libgen_mirror_configuration()
)
def search(
self,
book: BookMetadata,
plan: ReleaseSearchPlan,
*,
expand_search: bool = False,
content_type: str = "ebook",
) -> list[Release]:
"""Search libgen for releases of a book."""
if content_type != "ebook":
return []
if not self.is_available():
return []
queries = _build_query_candidates(plan, book)
if not queries:
return []
max_results = _coerce_positive_int(
config.get("LIBGEN_SEARCH_MAX_RESULTS", _DEFAULT_MAX_RESULTS), _DEFAULT_MAX_RESULTS
)
mirror_list = mirrors.get_libgen_mirrors()
# One search_libgen call per candidate; it already retries every mirror internally.
# Worst case (all mirrors dead) stays within the shared search deadline.
for query in queries:
logger.info("Searching Libgen for: %s", query)
records = scraper.search_libgen(query, mirror_list, max_results=max_results)
if records:
return [self._record_to_release(record) for record in records]
return []
def _record_to_release(self, record: BrowseRecord) -> Release:
"""Normalize a libgen catalogue record into a Release.
``source_id`` is namespaced ``libgen:<md5>`` so the download queue key never
collides with a direct_download release for the same md5 (Anna's Archive heavily
indexes libgen, so the same md5 routinely appears from both sources). The handler
strips the prefix back to the bare md5.
"""
return Release(
source="libgen",
source_id=f"libgen:{record.id}",
title=record.title,
format=record.format,
language=record.language,
size=record.size,
download_url=None, # handler builds ads.php?md5= from the md5
info_url=record.source_url,
protocol=ReleaseProtocol.HTTP,
indexer="Libgen",
content_type="ebook",
extra={
"author": record.author,
"year": record.year,
"md5": record.id,
"language": record.language,
},
)
def search_results_are_releases(self) -> bool:
"""Libgen search rows are concrete, directly downloadable releases."""
return True
def get_record(
self,
record_id: str,
*,
fetch_download_count: bool = True,
) -> BrowseRecord | None:
"""Resolve a libgen record by (possibly prefixed) md5, or None if not found."""
md5 = record_id.split(":", 1)[-1].lower()
return scraper.fetch_record_by_md5(md5, mirrors.get_libgen_mirrors())
def get_column_config(self) -> ReleaseColumnConfig:
"""Language, format and size badges -- same layout as Direct Download."""
return ReleaseColumnConfig(
columns=[
ColumnSchema(
key="extra.language",
label="Language",
render_type=ColumnRenderType.BADGE,
align=ColumnAlign.CENTER,
width="60px",
color_hint=ColumnColorHint(type="map", value="language"),
uppercase=True,
),
ColumnSchema(
key="format",
label="Format",
render_type=ColumnRenderType.BADGE,
align=ColumnAlign.CENTER,
width="80px",
color_hint=ColumnColorHint(type="map", value="format"),
uppercase=True,
),
ColumnSchema(
key="size",
label="Size",
render_type=ColumnRenderType.SIZE,
align=ColumnAlign.CENTER,
width="80px",
),
],
grid_template="minmax(0,2fr) 60px 80px 80px",
supported_filters=["format", "language"],
)
View File
+82
View File
@@ -0,0 +1,82 @@
"""Inline HTML samples for Libgen tests, trimmed to the real libgen.li structure.
The results table mixes two row shapes with NO rowspans: full 9-cell rows
``[Title, Author, Publisher, Year, Language, Pages, Size, Ext, Mirrors]`` and compact
5-cell rows ``[Title, Pages, Size, Ext, Mirrors]``. The md5 lives in the Mirrors cell.
"""
MD5_A = "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1" # 9-cell epub row (author + language present)
# A decoy md5 planted in row A's Title cell (as a cover-image URL). The parser must NOT pick
# it: md5 extraction is scoped to the Mirrors cell, so row A must resolve to MD5_A, not this.
DECOY_MD5 = "0000000000000000000000000000dead"
MD5_B = "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2" # 9-cell off-topic name-drop (cbr) - must survive
MD5_C = "c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3" # 5-cell compact manga volume (cbr)
MD5_D = "d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4" # 5-cell compact manga volume (cbz)
GET_KEY = "TESTKEY0001"
# A #tablelibgen with: header, 2 full rows (1 on-topic, 1 off-topic name-drop), 2 compact
# manga rows, a 1-cell spacer (len < 5 -> skipped), and a 5-cell row with no md5 (-> skipped).
SEARCH_HTML = f"""
<html><body>
<table id="tablelibgen">
<tr><th>Title</th><th>Author(s)</th><th>Publisher</th><th>Year</th><th>Language</th>
<th>Pages</th><th>Size</th><th>Ext.</th><th>Mirrors</th></tr>
<tr>
<td><img src="/covers/thumb.jpg?md5={DECOY_MD5}"><a href="edition.php?id=1">One Piece, Vol. 1</a></td>
<td>Eiichiro Oda</td><td>Viz Media</td><td>2003</td><td>English</td>
<td>216</td><td>180&nbsp;MB</td><td>epub</td>
<td><a href="/get.php?md5={MD5_A}">Libgen</a>
<a href="https://annas-archive.org/md5/{MD5_A}">Anna's</a></td>
</tr>
<tr>
<td>Ninja High School #127 Naruto, One Piece &amp; Kenshin</td>
<td>Ben Dunn</td><td>Antarctic Press</td><td>2005</td><td>English</td>
<td>24</td><td>8&nbsp;MB</td><td>cbr</td>
<td><a href="/get.php?md5={MD5_B}">Libgen</a></td>
</tr>
<tr>
<td>One Piece 515</td><td>19</td><td>6&nbsp;MB</td><td>cbr</td>
<td><a href="/get.php?md5={MD5_C}">Libgen</a></td>
</tr>
<tr>
<td>One Piece 516</td><td>20</td><td>7&nbsp;MB</td><td>cbz</td>
<td><a href="/get.php?md5={MD5_D}">Libgen</a></td>
</tr>
<tr><td colspan="9">-- section separator --</td></tr>
<tr>
<td>Advertisement</td><td></td><td></td><td></td>
<td><a href="/promo">Sponsored</a></td>
</tr>
</table>
</body></html>
"""
# A challenge/error page: no results table -> _parse_results returns None (try next mirror).
NO_TABLE_HTML = """
<html><body><div id="challenge">Checking your browser...</div></body></html>
"""
# A well-formed but empty results table -> _parse_results returns [] (accepted as final).
EMPTY_TABLE_HTML = """
<html><body>
<table id="tablelibgen">
<tr><th>Title</th><th>Ext.</th><th>Mirrors</th></tr>
</table>
</body></html>
"""
# An ads.php page: keyed GET link + the labelled metadata block (as visible text).
ADS_HTML = f"""
<html><head><title>Library Genesis</title></head><body>
<table>
<tr><td><a href="/get.php?md5={MD5_A}&key={GET_KEY}"><h2>GET</h2></a></td></tr>
<tr><td>Title: One Piece, Vol. 1 Series: One Piece Author(s): Eiichiro Oda
Publisher: Viz Media Year: 2003 ISBN: 9781234567890 Language: English Pages: 216</td></tr>
</table>
</body></html>
"""
# An ads.php page with no GET link (resolution should fail).
ADS_HTML_NO_GET = """
<html><body><p>File not found.</p></body></html>
"""
+43
View File
@@ -0,0 +1,43 @@
"""Tests for md5 -> record resolution via the ads.php metadata block."""
from unittest.mock import patch
from shelfmark.release_sources.libgen import scraper
from shelfmark.release_sources.libgen import source as libgen_source
from shelfmark.release_sources.libgen.source import LibgenSource
from tests.libgen import sample_html as html
def test_fetch_record_by_md5_parses_ads_metadata():
with patch.object(scraper, "fetch_page", return_value=html.ADS_HTML):
record = scraper.fetch_record_by_md5(html.MD5_A, ["https://libgen.li"])
assert record is not None
assert record.title == "One Piece, Vol. 1"
assert record.author == "Eiichiro Oda"
assert record.publisher == "Viz Media"
assert record.year == "2003" # stops at "ISBN:", not swallowed
assert record.language == "en"
assert record.source == "libgen"
assert record.id == html.MD5_A
def test_fetch_record_by_md5_titleless_page_returns_none():
with patch.object(scraper, "fetch_page", return_value=html.ADS_HTML_NO_GET):
assert scraper.fetch_record_by_md5(html.MD5_A, ["https://libgen.li"]) is None
def test_fetch_record_by_md5_all_mirrors_miss_returns_none():
with patch.object(scraper, "fetch_page", return_value=None):
assert scraper.fetch_record_by_md5(html.MD5_A, ["https://a", "https://b"]) is None
def test_get_record_strips_prefix_before_lookup():
with (
patch.object(
libgen_source.mirrors, "get_libgen_mirrors", return_value=["https://libgen.li"]
),
patch.object(libgen_source.scraper, "fetch_record_by_md5", return_value=None) as mock_fetch,
):
LibgenSource().get_record(f"libgen:{html.MD5_A}")
mock_fetch.assert_called_once()
assert mock_fetch.call_args.args[0] == html.MD5_A
+131
View File
@@ -0,0 +1,131 @@
"""Tests for LibgenHandler: prefix stripping, ads.php resolution, mirror fallthrough."""
import io
import threading
from unittest.mock import MagicMock, patch
from shelfmark.core.models import DownloadTask
from shelfmark.release_sources.libgen import handler as libgen_handler
from shelfmark.release_sources.libgen.handler import LibgenHandler
from tests.libgen import sample_html as html
def _task(task_id, fmt="cbr"):
return DownloadTask(
task_id=task_id, source="libgen", title="One Piece 515", format=fmt, size="6 MB"
)
def _buf(nbytes=20000):
buf = io.BytesIO(b"x" * nbytes)
buf.seek(0, io.SEEK_END) # download_url returns the buffer positioned at its end
return buf
def _run(task, tmp_path, *, mirrors_list, fetch_page, download_url):
status = MagicMock()
cancel = threading.Event()
with (
patch.object(libgen_handler, "TMP_DIR", tmp_path),
patch.object(
libgen_handler.config,
"get",
side_effect=lambda k, d=None: "none" if k == "FILE_ORGANIZATION" else d,
),
patch("shelfmark.core.mirrors.get_libgen_mirrors", return_value=mirrors_list),
patch.object(libgen_handler.scraper, "fetch_page", side_effect=fetch_page),
patch.object(libgen_handler.downloader, "download_url", side_effect=download_url),
):
result = LibgenHandler().download(task, cancel, MagicMock(), status)
return result, status
def test_download_strips_prefix_resolves_and_writes(tmp_path):
captured = {}
def fetch_page(url, timeout=(5, 10)):
return html.ADS_HTML
def download_url(link, size, prog, cancel, sel, status, referer=None):
captured["link"] = link
captured["referer"] = referer
captured["selector"] = sel
return _buf()
result, _ = _run(
_task(f"libgen:{html.MD5_A}"),
tmp_path,
mirrors_list=["https://libgen.li"],
fetch_page=fetch_page,
download_url=download_url,
)
expected = tmp_path / f"{html.MD5_A}.cbr"
assert result == str(expected)
assert expected.exists()
assert captured["link"] == f"https://libgen.li/get.php?md5={html.MD5_A}&key={html.GET_KEY}"
assert captured["referer"] == f"https://libgen.li/ads.php?md5={html.MD5_A}"
assert captured["selector"] is None # no AAMirrorSelector constructed
def test_download_accepts_bare_md5_task_id(tmp_path):
result, _ = _run(
_task(html.MD5_A),
tmp_path,
mirrors_list=["https://libgen.li"],
fetch_page=lambda url, timeout=(5, 10): html.ADS_HTML,
download_url=lambda *a, **k: _buf(),
)
assert result == str(tmp_path / f"{html.MD5_A}.cbr")
def test_download_falls_through_to_second_mirror(tmp_path):
def fetch_page(url, timeout=(5, 10)):
return None if "dead" in url else html.ADS_HTML
result, _ = _run(
_task(f"libgen:{html.MD5_A}"),
tmp_path,
mirrors_list=["https://dead.example", "https://libgen.li"],
fetch_page=fetch_page,
download_url=lambda *a, **k: _buf(),
)
assert result == str(tmp_path / f"{html.MD5_A}.cbr")
def test_download_all_mirrors_fail_returns_none(tmp_path):
result, status = _run(
_task(f"libgen:{html.MD5_A}"),
tmp_path,
mirrors_list=["https://a", "https://b"],
fetch_page=lambda url, timeout=(5, 10): None,
download_url=lambda *a, **k: _buf(),
)
assert result is None
status.assert_any_call("error", "All Libgen mirrors failed")
def test_download_too_small_file_is_rejected(tmp_path):
result, status = _run(
_task(f"libgen:{html.MD5_A}"),
tmp_path,
mirrors_list=["https://libgen.li"],
fetch_page=lambda url, timeout=(5, 10): html.ADS_HTML,
download_url=lambda *a, **k: _buf(100), # below _MIN_VALID_FILE_SIZE
)
assert result is None
status.assert_any_call("error", "All Libgen mirrors failed")
def test_download_cancelled_before_start(tmp_path):
status = MagicMock()
cancel = threading.Event()
cancel.set()
with (
patch.object(libgen_handler, "TMP_DIR", tmp_path),
patch("shelfmark.core.mirrors.get_libgen_mirrors", return_value=["https://libgen.li"]),
):
result = LibgenHandler().download(
_task(f"libgen:{html.MD5_A}"), cancel, MagicMock(), status
)
assert result is None
status.assert_any_call("cancelled", "Cancelled")
+92
View File
@@ -0,0 +1,92 @@
"""Tests for the Libgen scraper: results parsing and download-link resolution."""
from unittest.mock import patch
from shelfmark.release_sources.libgen import scraper
from tests.libgen import sample_html as html
def test_parse_results_extracts_all_md5_rows():
records = scraper._parse_results(html.SEARCH_HTML, "https://libgen.li")
assert records is not None
assert [r.id for r in records] == [html.MD5_A, html.MD5_B, html.MD5_C, html.MD5_D]
def test_parse_results_full_row_fields():
records = scraper._parse_results(html.SEARCH_HTML, "https://libgen.li")
a = records[0]
assert a.title == "One Piece, Vol. 1"
assert a.author == "Eiichiro Oda"
assert a.format == "epub"
assert a.size == "180 MB" # &nbsp; normalized to a plain space
assert a.language == "en"
assert a.source == "libgen"
assert a.source_url == f"https://libgen.li/ads.php?md5={html.MD5_A}"
def test_parse_results_md5_scoped_to_mirrors_cell():
# Row A's Title cell carries a decoy md5-shaped cover URL that appears BEFORE the real
# md5 in document order; the parser must resolve to the Mirrors-cell md5, not the decoy.
records = scraper._parse_results(html.SEARCH_HTML, "https://libgen.li")
assert records[0].id == html.MD5_A
assert html.DECOY_MD5 not in {r.id for r in records}
def test_parse_results_offtopic_row_survives():
# No relevance filter: a row that merely name-drops the query is kept, same as AA.
records = scraper._parse_results(html.SEARCH_HTML, "https://libgen.li")
assert any(r.id == html.MD5_B and r.format == "cbr" for r in records)
def test_parse_results_compact_rows_have_no_author_or_language():
records = scraper._parse_results(html.SEARCH_HTML, "https://libgen.li")
c = next(r for r in records if r.id == html.MD5_C)
assert c.title == "One Piece 515"
assert c.format == "cbr"
assert c.size == "6 MB"
assert c.author is None
assert c.language is None
def test_parse_results_comics_not_dropped_by_format():
records = scraper._parse_results(html.SEARCH_HTML, "https://libgen.li")
assert {"cbr", "cbz"} <= {r.format for r in records}
def test_parse_results_no_table_returns_none():
assert scraper._parse_results(html.NO_TABLE_HTML, "https://libgen.li") is None
def test_parse_results_empty_table_returns_empty_list():
result = scraper._parse_results(html.EMPTY_TABLE_HTML, "https://libgen.li")
assert result == []
assert result is not None # distinct from the no-table case
def test_resolve_download_url_extracts_keyed_get():
url = scraper.resolve_download_url(html.ADS_HTML, "https://libgen.li")
assert url == f"https://libgen.li/get.php?md5={html.MD5_A}&key={html.GET_KEY}"
def test_resolve_download_url_missing_get_returns_none():
assert scraper.resolve_download_url(html.ADS_HTML_NO_GET, "https://libgen.li") is None
def test_search_libgen_falls_through_dead_mirror():
calls = []
def fake_fetch(url, timeout=(5, 15)):
calls.append(url)
return None if "dead" in url else html.SEARCH_HTML
with patch.object(scraper, "fetch_page", side_effect=fake_fetch):
records = scraper.search_libgen(
"one piece", ["https://dead.example", "https://libgen.li"], max_results=25
)
assert len(records) == 4
assert len(calls) == 2 # dead mirror tried first, then the live one
def test_search_libgen_all_mirrors_dead_returns_empty():
with patch.object(scraper, "fetch_page", return_value=None):
assert scraper.search_libgen("q", ["https://a", "https://b"], max_results=25) == []
+138
View File
@@ -0,0 +1,138 @@
"""Tests for LibgenSource: availability gating, query building, record mapping."""
import types
from unittest.mock import patch
from shelfmark.release_sources import BrowseRecord, ReleaseProtocol
from shelfmark.release_sources.libgen import source as libgen_source
from shelfmark.release_sources.libgen.source import LibgenSource, _build_query_candidates
from tests.libgen import sample_html as html
def _plan(manual_query=None, variants=None):
return types.SimpleNamespace(
manual_query=manual_query, title_variants=variants or [], author=""
)
def _variant(title, author):
return types.SimpleNamespace(title=title, author=author)
def _book(title):
return types.SimpleNamespace(title=title)
class TestIsAvailable:
def _patches(self, *, enabled, has_mirrors):
return (
patch.object(
libgen_source.config,
"get",
side_effect=lambda k, d=None: enabled if k == "LIBGEN_SEARCH_ENABLED" else d,
),
patch.object(
libgen_source.mirrors, "has_libgen_mirror_configuration", return_value=has_mirrors
),
)
def test_enabled_with_mirrors(self):
cfg, mir = self._patches(enabled=True, has_mirrors=True)
with cfg, mir:
assert LibgenSource().is_available() is True
def test_disabled(self):
cfg, mir = self._patches(enabled=False, has_mirrors=True)
with cfg, mir:
assert LibgenSource().is_available() is False
def test_enabled_without_mirrors(self):
cfg, mir = self._patches(enabled=True, has_mirrors=False)
with cfg, mir:
assert LibgenSource().is_available() is False
def test_truthy_string_does_not_enable(self):
cfg = patch.object(
libgen_source.config,
"get",
side_effect=lambda k, d=None: "true" if k == "LIBGEN_SEARCH_ENABLED" else d,
)
mir = patch.object(
libgen_source.mirrors, "has_libgen_mirror_configuration", return_value=True
)
with cfg, mir:
assert LibgenSource().is_available() is False
def test_build_query_candidates_manual_query_wins():
plan = _plan(manual_query=" attack on titan ")
assert _build_query_candidates(plan, _book("ignored")) == ["attack on titan"]
def test_build_query_candidates_combined_then_title_only():
plan = _plan(variants=[_variant("One Piece", "Oda")])
assert _build_query_candidates(plan, _book("x")) == ["One Piece Oda", "One Piece"]
def test_build_query_candidates_dedups_when_no_author():
plan = _plan(variants=[_variant("Dune", "")])
assert _build_query_candidates(plan, _book("x")) == ["Dune"]
def test_build_query_candidates_book_title_fallback():
assert _build_query_candidates(_plan(), _book("Fallback Title")) == ["Fallback Title"]
def test_search_non_ebook_returns_empty():
with patch.object(LibgenSource, "is_available", return_value=True):
result = LibgenSource().search(
_book("x"), _plan(manual_query="x"), content_type="audiobook"
)
assert result == []
def test_search_unavailable_returns_empty():
with patch.object(LibgenSource, "is_available", return_value=False):
assert LibgenSource().search(_book("x"), _plan(manual_query="x")) == []
def test_search_maps_records_to_releases():
record = BrowseRecord(
id=html.MD5_A,
title="One Piece, Vol. 1",
source="libgen",
format="epub",
size="180 MB",
language="en",
author="Oda",
)
with (
patch.object(LibgenSource, "is_available", return_value=True),
patch.object(
libgen_source.mirrors, "get_libgen_mirrors", return_value=["https://libgen.li"]
),
patch.object(libgen_source.config, "get", side_effect=lambda k, d=None: d),
patch.object(libgen_source.scraper, "search_libgen", return_value=[record]) as mock_search,
):
releases = LibgenSource().search(_book("One Piece"), _plan(manual_query="One Piece"))
assert len(releases) == 1
assert releases[0].source_id == f"libgen:{html.MD5_A}"
mock_search.assert_called_once()
def test_record_to_release_namespaces_source_id_and_fields():
record = BrowseRecord(
id=html.MD5_C, title="One Piece 515", source="libgen", format="cbr", size="6 MB"
)
release = LibgenSource()._record_to_release(record)
assert release.source == "libgen"
assert release.source_id == f"libgen:{html.MD5_C}"
assert release.protocol == ReleaseProtocol.HTTP
assert release.indexer == "Libgen"
assert release.content_type == "ebook"
assert release.extra["md5"] == html.MD5_C
assert release.download_url is None
def test_search_results_are_releases():
assert LibgenSource().search_results_are_releases() is True