Files
shelfmark/tests/webshare/test_source.py
T
2007384058 feat(sources): add Webshare.cz as a release source (#1543)
## What

Adds [Webshare.cz](https://webshare.cz), the Czech file host, as a
release source for ebooks and audiobooks. It follows the Libgen source:
disabled by default, with a settings tab for credentials and a **Test
Login** button that reports whether the account is VIP.

## Why

Webshare is one of the main places Czech and Slovak ebooks and
audiobooks are shared, and none of the existing sources reach it.
Together with the Databáze knih metadata provider (#1479), this lets
Universal mode find Czech and Slovak titles end to end.

## How

- **API client** (`webshare/api.py`): Webshare's XML API (`POST
/api/<endpoint>/`). Login is the salted exchange the API documents:
fetch the salt, send `SHA1(MD5_CRYPT(password, salt))` plus an MD5
digest. Python dropped the `crypt` module in 3.13, so md5-crypt is a
small pure-Python port, checked against `openssl passwd -1`. The session
token is cached per account and refreshed once when an authenticated
call fails. File-level errors (`FILE_LINK_FATAL_*`) are not retried. XML
is parsed with `defusedxml`.
- **Search** (`webshare/source.py`): Webshare matches file names only,
so the file name becomes the release title and its extension the format.
Queries are title + author, then title only, or the manual query.
Results are filtered to the configured `SUPPORTED_FORMATS` /
`SUPPORTED_AUDIOBOOK_FORMATS`, plus `zip`/`rar` for audiobooks, since
they are usually uploaded as archives. Password-protected files are
skipped. The language is guessed from tags in the name (`CZ`, `SK`,
`ENG`, ...), and the vote balance is a sortable column. API failures
raise `SourceUnavailableError`.
- **Download** (`webshare/handler.py`): resolves a time-limited link per
attempt with `file_link`, then streams it into `TMP_DIR` with progress
and cancel support. Large audiobook archives are not buffered in memory.
- **Settings**: `WEBSHARE_ENABLED`, `WEBSHARE_USERNAME`,
`WEBSHARE_PASSWORD` (a `PasswordField`, already covered by the
`_PASSWORD` redaction in `genDebug.sh`), and `WEBSHARE_MAX_RESULTS`. The
`docs/environment-variables.md` section was added.

## Testing

- `tests/webshare/`: 31 offline tests. They cover md5-crypt vectors,
password hashing, XML parsing, token reuse and re-login, non-retried
file errors, availability gating, format filtering, query fallback,
release mapping, language guessing, and the handler's streaming,
small-file rejection, link errors and cancel.
- `ruff check` and `ruff format --check` are clean. The rest of the unit
suite fails the same way with and without this change: Windows-only
tests on my machine.
- Verified live against webshare.cz: search parsing and the salt
endpoint's error path.
- Still a draft: I haven't yet verified login and download with a real
account.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: CaliBrain <calibrain@l4n.xyz>
2026-10-22 20:30:01 -04:00

165 lines
5.4 KiB
Python

"""Tests for WebshareSource: availability, format filtering, release mapping."""
from unittest.mock import patch
import pytest
from shelfmark.core.search_plan import ReleaseSearchPlan, ReleaseSearchVariant
from shelfmark.metadata_providers import BookMetadata
from shelfmark.release_sources import SourceUnavailableError, get_source
from shelfmark.release_sources.webshare import source as webshare_source
from shelfmark.release_sources.webshare.api import WebshareError, WebshareFile
_CONFIG = {
"WEBSHARE_ENABLED": True,
"WEBSHARE_USERNAME": " user ",
"WEBSHARE_PASSWORD": "pw",
"WEBSHARE_MAX_RESULTS": 20,
"SUPPORTED_FORMATS": ["epub", "mobi"],
"SUPPORTED_AUDIOBOOK_FORMATS": ["m4b", "mp3"],
}
def _file(ident, name, *, size=200_000, password=False, up=0, down=0):
return WebshareFile(
ident=ident,
name=name,
type=None,
size_bytes=size,
positive_votes=up,
negative_votes=down,
password_protected=password,
)
def _book():
return BookMetadata(provider="test", provider_id="1", title="Krysař", authors=["Viktor Dyk"])
def _plan(**kwargs):
return ReleaseSearchPlan(
languages=None,
isbn_candidates=[],
author="Viktor Dyk",
title_variants=[ReleaseSearchVariant(title="Krysař", author="Viktor Dyk")],
grouped_title_variants=[],
**kwargs,
)
@pytest.fixture
def config_values():
values = dict(_CONFIG)
with (
patch.object(webshare_source.config, "get", side_effect=lambda k, d=None: values.get(k, d)),
patch(
"shelfmark.download.postprocess.policy.core_config.config.get",
side_effect=lambda k, d=None: values.get(k, d),
),
):
yield values
def test_registered_under_webshare():
assert get_source("webshare").display_name == "Webshare.cz"
@pytest.mark.parametrize(
("overrides", "expected"),
[
({}, True),
({"WEBSHARE_ENABLED": False}, False),
({"WEBSHARE_ENABLED": "true"}, False),
({"WEBSHARE_USERNAME": ""}, False),
({"WEBSHARE_PASSWORD": ""}, False),
],
)
def test_is_available(config_values, overrides, expected):
config_values.update(overrides)
assert webshare_source.WebshareSource().is_available() is expected
def test_ebook_search_filters_formats_and_maps_release(config_values):
files = [
_file("a1", "Dyk Viktor - Krysar (CZ).epub", up=5, down=1),
_file("a2", "Krysar.mobi"),
_file("a3", "Krysar.mp3"),
_file("a4", "Krysar.zip"),
_file("a5", "Krysar locked.epub", password=True),
]
with patch.object(webshare_source.WebshareClient, "search", return_value=files) as search:
releases = webshare_source.WebshareSource().search(_book(), _plan())
assert [r.source_id for r in releases] == ["webshare:a1", "webshare:a2"]
search.assert_called_once_with("Krysař Viktor Dyk", limit=20)
first = releases[0]
assert first.title == "Dyk Viktor - Krysar (CZ)"
assert first.format == "epub"
assert first.language == "cs"
assert first.size == "195.3 KB"
assert first.size_bytes == 200_000
assert first.info_url == "https://webshare.cz/#/file/a1"
assert first.extra["votes"] == 4
assert first.content_type == "ebook"
def test_audiobook_search_accepts_archives(config_values):
files = [_file("b1", "Krysar audiokniha.zip"), _file("b2", "Krysar.epub")]
with patch.object(webshare_source.WebshareClient, "search", return_value=files):
releases = webshare_source.WebshareSource().search(
_book(), _plan(), content_type="audiobook"
)
assert [r.source_id for r in releases] == ["webshare:b1"]
assert releases[0].content_type == "audiobook"
def test_falls_back_to_title_only_query(config_values):
calls = []
def search(query, limit):
calls.append(query)
return [] if len(calls) == 1 else [_file("c1", "Krysar.epub")]
with patch.object(webshare_source.WebshareClient, "search", side_effect=search):
releases = webshare_source.WebshareSource().search(_book(), _plan())
assert calls == ["Krysař Viktor Dyk", "Krysař"]
assert len(releases) == 1
def test_manual_query_is_used_verbatim(config_values):
with patch.object(webshare_source.WebshareClient, "search", return_value=[]) as search:
webshare_source.WebshareSource().search(_book(), _plan(manual_query=" dyk krysar "))
search.assert_called_once_with("dyk krysar", limit=20)
def test_search_error_marks_source_unavailable(config_values):
with (
patch.object(
webshare_source.WebshareClient, "search", side_effect=WebshareError("Login failed")
),
pytest.raises(SourceUnavailableError),
):
webshare_source.WebshareSource().search(_book(), _plan())
def test_disabled_source_does_not_search(config_values):
config_values["WEBSHARE_ENABLED"] = False
with patch.object(webshare_source.WebshareClient, "search") as search:
assert webshare_source.WebshareSource().search(_book(), _plan()) == []
search.assert_not_called()
@pytest.mark.parametrize(
("name", "expected"),
[
("Kniha (CZ).epub", "cs"),
("kniha.sk.epub", "sk"),
("Book [ENG].epub", "en"),
("Kniha CZ + EN.epub", None),
("Kniha.epub", None),
],
)
def test_guess_language(name, expected):
assert webshare_source._guess_language(name) == expected