Find books through CWA's OPDS feed and use its ingest folder
Bindery mounts no CWA database. It searches the OPDS feed by title and reads the story URL inside each candidate epub to find the library book. Updates go to the ingest folder with a .cwa.json sidecar, so CWA replaces the epub of the existing book.
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
"""The OPDS client, the book finder, the login check, the ingest folder and the epub reader."""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from fake_cwa import FakeBook, FakeCwa
|
||||
from test_build import URL, chapters, metahtml
|
||||
|
||||
from bindery import fff
|
||||
from bindery.cwa import BookFinder, CwaClient, CwaError, plain_title, search_text, title_rank
|
||||
from bindery.epub import epub_metadata, epub_urls
|
||||
from bindery.ingest import MAX_NAME_LENGTH, IngestFolder
|
||||
|
||||
STORY_URL = "https://www.royalroad.com/fiction/1234"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def story_epub(tmp_path_factory):
|
||||
path = tmp_path_factory.mktemp("epub") / "story.epub"
|
||||
fff.build_epub(URL, metahtml(), chapters(), [], None, [], {"series": ["Saga", 2.0]}, path)
|
||||
return path.read_bytes()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def other_epub(tmp_path_factory):
|
||||
path = tmp_path_factory.mktemp("epub") / "other.epub"
|
||||
other_url = "https://www.royalroad.com/fiction/9999/other-story"
|
||||
other_chapters = [dict(c, url=c["url"].replace("1234/test-story", "9999/other-story")) for c in chapters()]
|
||||
fff.build_epub(other_url, metahtml(other_url), other_chapters, [], None, [], {}, path)
|
||||
return path.read_bytes()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake(story_epub, other_epub):
|
||||
server = FakeCwa(page_size=1)
|
||||
other = other_epub
|
||||
server.books = [
|
||||
FakeBook(1, "uuid-1", "Test Story", publisher="www.royalroad.com", epub=other),
|
||||
FakeBook(2, "uuid-2", "Test Story", publisher="www.royalroad.com", epub=story_epub),
|
||||
FakeBook(3, "uuid-3", "Another Book", ["Someone"], publisher="Tor", epub=b"not a zip"),
|
||||
FakeBook(4, "uuid-4", "Test Story Two", publisher="www.royalroad.com"),
|
||||
]
|
||||
yield server
|
||||
server.close()
|
||||
|
||||
|
||||
def client(fake, password="secret"):
|
||||
return CwaClient(fake.url, "bindery", password)
|
||||
|
||||
|
||||
def test_search_reads_ids_and_uuids(fake):
|
||||
books = client(fake).search("test story")
|
||||
# Book 4 has no files, so OPDS shows no download link and no book id for it.
|
||||
assert [(b.id, b.uuid) for b in books] == [(1, "uuid-1"), (2, "uuid-2")]
|
||||
assert books[0].formats == {"epub": "/opds/download/1/epub/"}
|
||||
assert books[0].publisher == "www.royalroad.com"
|
||||
|
||||
|
||||
def test_wrong_password_and_missing_address(fake):
|
||||
with pytest.raises(CwaError, match="refused"):
|
||||
client(fake, "wrong").search("x")
|
||||
with pytest.raises(CwaError, match="not set"):
|
||||
CwaClient("").search("x")
|
||||
|
||||
|
||||
def test_get_book(fake):
|
||||
c = client(fake)
|
||||
assert c.get_book("uuid-2") == {
|
||||
"id": 2,
|
||||
"uuid": "uuid-2",
|
||||
"title": "Test Story",
|
||||
"authors": ["Writer"],
|
||||
"formats": ["EPUB"],
|
||||
}
|
||||
assert c.get_book("nope") is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("book_uuid", ["../opds", "uuid-2?x=1", "uuid-2#x", "uuid%2F2", ""])
|
||||
def test_get_book_refuses_a_uuid_that_changes_the_path(book_uuid):
|
||||
# Nothing listens on this port, so a request would raise CwaError.
|
||||
assert CwaClient("http://127.0.0.1:9").get_book(book_uuid) is None
|
||||
|
||||
|
||||
def test_site_books(fake):
|
||||
books = client(fake).site_books(fff.is_supported_site)
|
||||
assert [b.id for b in books] == [1, 2] # book 4 has no epub, book 3 is not from a story site
|
||||
assert any("offset=2" in r for r in fake.requests) # the publisher feed has three pages
|
||||
|
||||
|
||||
def test_site_books_names_the_cwa_setting_that_shows_publishers(fake, monkeypatch):
|
||||
monkeypatch.setattr(fake, "publisher_view", False)
|
||||
with pytest.raises(CwaError, match='Turn on "Show Publisher Section" for Bindery in CWA'):
|
||||
client(fake).site_books(fff.is_supported_site)
|
||||
|
||||
|
||||
def test_finder_checks_the_url_in_each_epub(fake, tmp_path):
|
||||
finder = BookFinder()
|
||||
found = finder.find(client(fake), STORY_URL, ["test story"], fff.normalize_url, tmp_path)
|
||||
assert found is not None
|
||||
book, path = found
|
||||
assert book.id == 2 and path.exists()
|
||||
# With book 2 excluded, book 1 is checked once and remembered as another story.
|
||||
assert finder.find(client(fake), STORY_URL, ["Test Story"], fff.normalize_url, tmp_path, {2}) is None
|
||||
fake.requests.clear()
|
||||
assert finder.find(client(fake), STORY_URL, ["Test Story"], fff.normalize_url, tmp_path, {2}) is None
|
||||
assert not [r for r in fake.requests if r.startswith("/opds/download/")]
|
||||
assert sorted(p.name for p in tmp_path.iterdir()) == [path.name] # rejected downloads are deleted
|
||||
|
||||
|
||||
TAGGED = "Story A [Tag A, Tag B] (Book 1 Complete)"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"title, plain, search",
|
||||
[
|
||||
(TAGGED, "story a", "Story A"),
|
||||
("Story A - (Book 1)", "story a", "Story A"),
|
||||
("[AU] Story A [Tag] Part I", "story a part i", "story a part i"),
|
||||
("Story (A [B]) C", "story c", "Story"),
|
||||
("Plain Title", "plain title", "Plain Title"),
|
||||
],
|
||||
)
|
||||
def test_titles_without_their_bracketed_parts(title, plain, search):
|
||||
assert (plain_title(title), search_text(title)) == (plain, search)
|
||||
|
||||
|
||||
def test_title_rank_prefers_the_closest_title():
|
||||
titles = [
|
||||
"Story A [Tag A, Tag B] Part I",
|
||||
"Story A",
|
||||
"Story A [Tag A, Tag B]",
|
||||
TAGGED,
|
||||
"Story Ab",
|
||||
"Another Story A",
|
||||
]
|
||||
ranked = sorted((t for t in titles if title_rank(t, [TAGGED])), key=lambda t: title_rank(t, [TAGGED]))
|
||||
assert ranked == [TAGGED, "Story A [Tag A, Tag B]", "Story A", "Story A [Tag A, Tag B] Part I"]
|
||||
assert title_rank("Story A [Tag A, Tag B]", ["Story A"])[0] == 1
|
||||
assert title_rank("Story A Part I", ["Story A Part II"]) is None
|
||||
|
||||
|
||||
def test_finder_downloads_the_closest_titles_first(fake, other_epub, tmp_path):
|
||||
fake.books = [
|
||||
FakeBook(9, "uuid-9", TAGGED, epub=other_epub),
|
||||
FakeBook(10, "uuid-10", "Story A", epub=other_epub),
|
||||
FakeBook(11, "uuid-11", "Story A [Tag A, Tag B] Part I", epub=other_epub),
|
||||
FakeBook(12, "uuid-12", "Story A [Tag A, Tag B]", epub=other_epub),
|
||||
FakeBook(13, "uuid-13", "Story Ab", epub=other_epub),
|
||||
FakeBook(14, "uuid-14", "Another Story A", epub=other_epub),
|
||||
]
|
||||
assert BookFinder().find(client(fake), STORY_URL, [TAGGED], fff.normalize_url, tmp_path) is None
|
||||
assert [r for r in fake.requests if r.startswith("/opds/search")] == ["/opds/search?query=Story+A"]
|
||||
downloads = [int(r.split("/")[3]) for r in fake.requests if r.startswith("/opds/download/")]
|
||||
assert downloads == [9, 12, 10, 11]
|
||||
|
||||
|
||||
def test_login_ok(fake):
|
||||
c = CwaClient(fake.url)
|
||||
assert c.login_ok("bindery", "secret")
|
||||
assert not c.login_ok("bindery", "wrong")
|
||||
fake.close()
|
||||
with pytest.raises(CwaError, match="Cannot reach"):
|
||||
c.login_ok("bindery", "secret")
|
||||
|
||||
|
||||
def test_epub_reader(story_epub, tmp_path):
|
||||
path = tmp_path / "s.epub"
|
||||
path.write_bytes(story_epub)
|
||||
assert STORY_URL in [fff.normalize_url(u)[0] for u in epub_urls(path)]
|
||||
meta = epub_metadata(path)
|
||||
assert meta["title"] == "Test Story"
|
||||
assert meta["authors"] == ["Writer"]
|
||||
assert meta["series"] == "Saga" and meta["series_index"] == 2.0
|
||||
assert meta["pubdate"].startswith("2024-01-02")
|
||||
|
||||
|
||||
def test_ingest_uses_part_names_and_unique_names(tmp_path):
|
||||
folder = IngestFolder(tmp_path / "ingest")
|
||||
epub = tmp_path / "x.epub"
|
||||
epub.write_bytes(b"PK")
|
||||
first = folder.ingest(epub, 'A: "Story" / Name')
|
||||
second = folder.ingest(epub, 'A: "Story" / Name')
|
||||
assert first != second
|
||||
assert first.name.startswith("A_ _Story_ _ Name ") and first.suffix == ".epub"
|
||||
assert sorted(p.name for p in folder.path.iterdir()) == sorted([first.name, second.name])
|
||||
|
||||
|
||||
def test_send_update_writes_sidecar(tmp_path):
|
||||
folder = IngestFolder(tmp_path / "ingest")
|
||||
epub = tmp_path / "x.epub"
|
||||
epub.write_bytes(b"PK")
|
||||
path = folder.send_update(epub, 7, "T" * 300)
|
||||
assert sorted(p.name for p in folder.path.iterdir()) == [path.name, path.name + ".cwa.json"]
|
||||
assert len(path.name) <= MAX_NAME_LENGTH + len(".epub")
|
||||
manifest = json.loads((folder.path / (path.name + ".cwa.json")).read_text())
|
||||
assert manifest == {"action": "add_format", "book_id": 7}
|
||||
assert path.read_bytes() == b"PK"
|
||||
Reference in New Issue
Block a user