Run download jobs and deliver books to CWA

Jobs download new chapters, recheck chapters, replace the book, update
metadata or rebuild from stored chapters. Locked metadata fields keep
their values. A scheduler queues updates. An update finds the library
book of a story through CWA, and fails with an error if it cannot, so
that it never makes a duplicate book.
This commit is contained in:
tim
2026-09-27 23:38:28 +02:00
parent 312dc3efc5
commit 49cd18a09f
8 changed files with 2581 additions and 0 deletions
+163
View File
@@ -0,0 +1,163 @@
"""The job queue: shared jobs, order, cancelling and the job history."""
from datetime import datetime, timedelta
from bindery import config
from bindery.jobs import DownloadMode, Job, JobQueue, QueueStatus
def job(mode=DownloadMode.NEW, **fields):
return Job(mode=mode, **fields)
def test_a_waiting_job_is_shared():
queue = JobQueue()
first = queue.add(job(story_id=1))
assert queue.add(job(story_id=1)) is first
assert queue.add(job(url="https://example.com/s/1")) is not first
assert queue.add(job(url="https://example.com/s/1")).url == "https://example.com/s/1"
assert queue.add(job(DownloadMode.REBUILD, story_id=1)) is not first
assert queue.add(job(story_id=2)) is not first
assert queue.add(job(story_id=1, force_fields=["title"])) is not first
def test_rechecks_of_other_chapters_are_separate_jobs():
queue = JobQueue()
one = queue.add(job(DownloadMode.RECHECK, story_id=1, chapter_ids=[1]))
assert queue.add(job(DownloadMode.RECHECK, story_id=1, chapter_ids=[2])) is not one
assert queue.add(job(DownloadMode.RECHECK, story_id=1, chapter_ids=[1])) is one
def test_uploads_are_separate_jobs():
queue = JobQueue()
one = queue.add(job(DownloadMode.IMPORT_FILE, story_id=1, options={"path": "a.epub"}))
assert queue.add(job(DownloadMode.IMPORT_FILE, story_id=1, options={"path": "b.epub"})) is not one
def test_a_started_job_is_not_shared():
queue = JobQueue()
first = queue.add(job(story_id=1))
assert queue.get_next() is first
assert queue.add(job(story_id=1)) is not first
def test_jobs_run_in_order_and_cancelled_jobs_are_skipped():
queue = JobQueue()
a, b, c = (queue.add(job(story_id=n)) for n in (1, 2, 3))
assert queue.cancel(b.id)
assert b.status == QueueStatus.CANCELLED
assert queue.get_next() is a
assert queue.get_next() is c
assert queue.get_next() is None
def test_a_cancelled_job_is_not_shared():
queue = JobQueue()
cancelled = queue.add(job(story_id=1))
queue.cancel(cancelled.id)
again = queue.add(job(story_id=1))
assert again is not cancelled and again.status == QueueStatus.QUEUED
assert queue.get_next() is again
def test_a_cancelled_job_can_expire_before_the_queue_reaches_it():
queue = JobQueue()
queue.add(job(story_id=1))
running = queue.get_next()
cancelled = queue.add(job(story_id=2))
queue.cancel(cancelled.id)
cancelled.updated = datetime.now() - timedelta(seconds=config.JOB_HISTORY + 1)
assert cancelled.id not in [j["id"] for j in queue.get_status()]
queue.update(running, status=QueueStatus.DONE)
later = queue.add(job(story_id=3))
assert queue.get_next() is later
def test_only_waiting_jobs_can_be_cancelled():
queue = JobQueue()
running = queue.add(job(story_id=1))
queue.update(queue.get_next(), status=QueueStatus.RUNNING)
assert not queue.cancel(running.id)
assert not queue.cancel("no-such-job")
def test_has_active():
queue = JobQueue()
first = queue.add(job(story_id=1))
assert queue.has_active(1) and not queue.has_active(2)
queue.update(first, status=QueueStatus.RUNNING)
assert queue.has_active(1)
queue.update(first, status=QueueStatus.DONE)
assert not queue.has_active(1)
def test_status_is_newest_first_and_filters_by_story():
queue = JobQueue()
old = queue.add(job(story_id=1, created=datetime.now() - timedelta(minutes=5)))
new = queue.add(job(DownloadMode.REBUILD, story_id=1))
other = queue.add(job(story_id=2))
assert [j["id"] for j in queue.get_status(1)] == [new.id, old.id]
assert {j["id"] for j in queue.get_status()} == {old.id, new.id, other.id}
data = queue.get_status(2)[0]
assert data["mode"] == "new" and data["status"] == "queued"
assert data["mode_label"] == "Download new chapters (default)"
assert datetime.fromisoformat(data["created"])
def test_finished_jobs_expire():
queue = JobQueue()
done = queue.add(job(story_id=1))
waiting = queue.add(job(story_id=2))
long_ago = datetime.now() - timedelta(seconds=config.JOB_HISTORY + 1)
queue.update(done, status=QueueStatus.DONE)
done.updated = long_ago
waiting.updated = long_ago
assert [j["id"] for j in queue.get_status()] == [waiting.id]
def test_jobs_outlive_a_restart(tmp_path):
queue = JobQueue(tmp_path / "db.sqlite")
running = queue.add(job(story_id=1))
waiting = queue.add(job(story_id=2, options={"x": [1]}))
done = queue.add(job(story_id=3))
cancelled = queue.add(job(story_id=4))
queue.update(queue.get_next(), status=QueueStatus.RUNNING, result={"new": 2})
queue.update(done, status=QueueStatus.DONE)
queue.cancel(cancelled.id)
again = JobQueue(tmp_path / "db.sqlite")
status = {j["id"]: j for j in again.get_status()}
assert (status[running.id]["status"], status[running.id]["restarts"]) == ("queued", 1)
assert status[running.id]["message"] == "Resumed"
assert status[running.id]["result"] == {"new": 2}
assert status[done.id]["status"] == "done" and status[cancelled.id]["status"] == "cancelled"
# The interrupted job goes first.
assert again.get_next().id == running.id
nxt = again.get_next()
assert (nxt.id, nxt.options, nxt.mode) == (waiting.id, {"x": [1]}, DownloadMode.NEW)
assert again.get_next() is None
def test_a_job_that_stops_bindery_again_and_again_is_given_up(tmp_path):
path = tmp_path / "db.sqlite"
queue = JobQueue(path)
first = queue.add(job(story_id=1))
queue.update(queue.get_next(), status=QueueStatus.RUNNING)
for restarts in (1, 2):
queue = JobQueue(path)
started = queue.get_next()
assert (started.id, started.restarts) == (first.id, restarts)
queue.update(started, status=QueueStatus.RUNNING)
queue = JobQueue(path)
assert queue.get_next() is None
(gone,) = queue.get_status()
assert gone["status"] == "error" and "3 times" in gone["message"]
def test_finished_jobs_leave_the_database(tmp_path, monkeypatch):
queue = JobQueue(tmp_path / "db.sqlite")
old = queue.add(job(story_id=1))
queue.update(old, status=QueueStatus.DONE)
old.updated = datetime.now() - timedelta(seconds=config.JOB_HISTORY + 1)
queue.get_status()
assert JobQueue(tmp_path / "db.sqlite").get_status() == []