mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-09-28 16:45:08 +01:00
## Headline features ### Prowlarr plugin - search trackers and download usenet/torrent books - Search any usenet/torrent tracker via Prowlarr, returns books within Universal search - Configure download clients in the app settings (Qbittorrent, Deluge, Transmission, NZBget, SABnzbd) - Unified download and file handling within the app, same as AA. ### IRC plugin - Search IRCHighway #ebooks channel for books and download right in the app. - No setup needed - Credit to OpenBooks for the broad idea and inspiration for best practices for ebook-specific search and download. ### Google Books Metadata Provider - Create a Google Cloud API key and use Google Books as a metadata provider - Not the best source (Hardcover is still recommended), but another option and further redundancy for universal search ### Book series support - New "Series" search field in Hardcover provider - "Series order" sort option - lists books in reading order - "View Series" button in book details modal to search the full series - Series info display (e.g., "3 of 12 in The Wheel of Time") ## Others: - Better format filtering, helpful errors when formats rejected (e.g., "Found 3 ebooks but format not supported (.pdf). Enable in Settings > Formats." - Directory processing - Handles multi-file torrent/usenet downloads properly - Expand search toggle - Skip ISBN search to find more editions - Filtered authors - Uses primary authors only (excludes translators/narrators) for better search results - Language multi-select - Filter releases by multiple languages Docker / Build / Testing - pip cache mounts - Faster Docker builds via BuildKit cache - npm cache mounts - Faster frontend builds - APT cleanup - Smaller final image size - Added make restart command for quick restarts without rebuild - New pytest-based test framework with proper configuration (pyproject.toml) - Unit tests for all download clients (qBittorrent, Transmission, Deluge, NZBGet, SABnzbd) - Bencode parsing tests - Cache tests - Integration tests for Prowlarr handler - E2E test framework
388 lines
12 KiB
Python
388 lines
12 KiB
Python
"""
|
|
E2E Download Flow Tests.
|
|
|
|
These tests verify the complete download journey from search to file retrieval.
|
|
They require external services to be available and may take longer to run.
|
|
|
|
Run with: docker exec test-cwabd python3 -m pytest tests/e2e/test_download_flow.py -v -m e2e
|
|
"""
|
|
|
|
import os
|
|
import hashlib
|
|
import time
|
|
|
|
import pytest
|
|
|
|
from .conftest import APIClient, DownloadTracker, DOWNLOAD_TIMEOUT
|
|
|
|
|
|
def _find_available_provider(api_client: APIClient) -> str | None:
|
|
"""Find a working metadata provider."""
|
|
resp = api_client.get("/api/metadata/providers")
|
|
if resp.status_code != 200:
|
|
return None
|
|
|
|
providers_data = resp.json()
|
|
|
|
# Handle both dict and list formats
|
|
if isinstance(providers_data, dict):
|
|
# Dict format: keys are provider names
|
|
provider_names = list(providers_data.keys())
|
|
else:
|
|
# List format
|
|
provider_names = [p.get("name") for p in providers_data if isinstance(p, dict) and p.get("name")]
|
|
|
|
for name in provider_names:
|
|
if name:
|
|
# Try a simple search to verify it works
|
|
test_resp = api_client.get(
|
|
"/api/metadata/search",
|
|
params={"query": "test", "provider": name},
|
|
timeout=30,
|
|
)
|
|
if test_resp.status_code == 200:
|
|
return name
|
|
return None
|
|
|
|
|
|
def _find_available_release_source(api_client: APIClient) -> str | None:
|
|
"""Find a working release source."""
|
|
resp = api_client.get("/api/release-sources")
|
|
if resp.status_code != 200:
|
|
return None
|
|
|
|
sources = resp.json()
|
|
for source in sources:
|
|
name = source.get("name")
|
|
# Skip prowlarr unless configured
|
|
if name and name != "prowlarr":
|
|
return name
|
|
return None
|
|
|
|
|
|
@pytest.mark.e2e
|
|
@pytest.mark.slow
|
|
class TestMetadataToReleaseFlow:
|
|
"""Test the flow from metadata search to release listing."""
|
|
|
|
def test_search_to_releases_flow(self, api_client: APIClient):
|
|
"""Test searching metadata then finding releases."""
|
|
# Find a working provider
|
|
provider = _find_available_provider(api_client)
|
|
if not provider:
|
|
pytest.skip("No metadata providers available")
|
|
|
|
# Search for a public domain book
|
|
search_resp = api_client.get(
|
|
"/api/metadata/search",
|
|
params={"query": "Moby Dick Herman Melville", "provider": provider},
|
|
timeout=30,
|
|
)
|
|
|
|
if search_resp.status_code != 200:
|
|
pytest.skip(f"Search failed: {search_resp.status_code}")
|
|
|
|
search_data = search_resp.json()
|
|
results = search_data.get("results", search_data)
|
|
|
|
# Handle dict format where results might be nested
|
|
if isinstance(results, dict):
|
|
# Results might be under a key like the query or "results"
|
|
for key, value in results.items():
|
|
if isinstance(value, list) and value:
|
|
results = value
|
|
break
|
|
|
|
if not results or not isinstance(results, list):
|
|
pytest.skip("No search results returned")
|
|
|
|
# Get the first result
|
|
first_result = results[0]
|
|
book_id = first_result.get("id") or first_result.get("provider_id")
|
|
assert book_id, "Search result missing ID"
|
|
|
|
# Now search for releases
|
|
releases_resp = api_client.get(
|
|
"/api/releases",
|
|
params={
|
|
"provider": provider,
|
|
"book_id": book_id,
|
|
"title": first_result.get("title", ""),
|
|
"author": first_result.get("author", ""),
|
|
},
|
|
timeout=60,
|
|
)
|
|
|
|
# Releases may fail if sources are unavailable
|
|
if releases_resp.status_code == 200:
|
|
releases_data = releases_resp.json()
|
|
assert "releases" in releases_data
|
|
assert "book" in releases_data
|
|
|
|
|
|
@pytest.mark.e2e
|
|
@pytest.mark.slow
|
|
class TestFullDownloadJourney:
|
|
"""
|
|
Test the complete download journey.
|
|
|
|
This test:
|
|
1. Searches for a book
|
|
2. Finds releases
|
|
3. Queues a download
|
|
4. Waits for completion
|
|
5. Verifies the file exists
|
|
"""
|
|
|
|
def test_complete_download_flow(
|
|
self, api_client: APIClient, download_tracker: DownloadTracker
|
|
):
|
|
"""Test the complete search -> download -> verify flow."""
|
|
# Find a working provider
|
|
provider = _find_available_provider(api_client)
|
|
if not provider:
|
|
pytest.skip("No metadata providers available")
|
|
|
|
# Search for a public domain book
|
|
search_resp = api_client.get(
|
|
"/api/metadata/search",
|
|
params={"query": "Pride and Prejudice Jane Austen", "provider": provider},
|
|
timeout=30,
|
|
)
|
|
|
|
if search_resp.status_code != 200:
|
|
pytest.skip(f"Metadata search unavailable: {search_resp.status_code}")
|
|
|
|
search_data = search_resp.json()
|
|
results = search_data.get("results", search_data)
|
|
|
|
# Handle dict format where results might be nested
|
|
if isinstance(results, dict):
|
|
for key, value in results.items():
|
|
if isinstance(value, list) and value:
|
|
results = value
|
|
break
|
|
|
|
if not results or not isinstance(results, list):
|
|
pytest.skip("No search results")
|
|
|
|
first_result = results[0]
|
|
book_id = first_result.get("id") or first_result.get("provider_id")
|
|
|
|
# Get releases
|
|
releases_resp = api_client.get(
|
|
"/api/releases",
|
|
params={
|
|
"provider": provider,
|
|
"book_id": book_id,
|
|
"title": first_result.get("title", ""),
|
|
},
|
|
timeout=60,
|
|
)
|
|
|
|
if releases_resp.status_code != 200:
|
|
pytest.skip(f"Releases unavailable: {releases_resp.status_code}")
|
|
|
|
releases_data = releases_resp.json()
|
|
releases = releases_data.get("releases", [])
|
|
|
|
if not releases:
|
|
pytest.skip("No releases available")
|
|
|
|
# Find an epub release (prefer smaller files)
|
|
target_release = None
|
|
for release in releases:
|
|
fmt = release.get("format", "").lower()
|
|
if fmt == "epub":
|
|
target_release = release
|
|
break
|
|
|
|
if not target_release:
|
|
# Fall back to first release
|
|
target_release = releases[0]
|
|
|
|
# Queue the download
|
|
source_id = target_release.get("source_id") or target_release.get("id")
|
|
download_tracker.track(source_id)
|
|
|
|
queue_resp = api_client.post(
|
|
"/api/releases/download",
|
|
json={
|
|
"source": target_release.get("source", "direct_download"),
|
|
"source_id": source_id,
|
|
"title": target_release.get("title", "Test Book"),
|
|
"format": target_release.get("format"),
|
|
"size": target_release.get("size"),
|
|
},
|
|
)
|
|
|
|
assert queue_resp.status_code == 200, f"Failed to queue: {queue_resp.text}"
|
|
queue_data = queue_resp.json()
|
|
assert queue_data.get("status") == "queued"
|
|
|
|
# Wait for download to complete (or error)
|
|
result = download_tracker.wait_for_status(
|
|
source_id,
|
|
target_states=["complete", "done", "available"],
|
|
timeout=DOWNLOAD_TIMEOUT,
|
|
)
|
|
|
|
if result is None:
|
|
# Check if it errored
|
|
status_resp = api_client.get("/api/status")
|
|
if status_resp.status_code == 200:
|
|
status_data = status_resp.json()
|
|
if "error" in status_data and source_id in status_data["error"]:
|
|
error_info = status_data["error"][source_id]
|
|
pytest.skip(f"Download failed: {error_info}")
|
|
pytest.fail("Download timed out")
|
|
|
|
assert result["state"] in ["complete", "done", "available"]
|
|
|
|
|
|
@pytest.mark.e2e
|
|
@pytest.mark.slow
|
|
class TestLegacyDownloadFlow:
|
|
"""Test the legacy download API (for backwards compatibility)."""
|
|
|
|
def test_legacy_search_and_download(
|
|
self, api_client: APIClient, download_tracker: DownloadTracker
|
|
):
|
|
"""Test the legacy search -> info -> download flow."""
|
|
# Use the legacy search endpoint
|
|
search_resp = api_client.get(
|
|
"/api/search",
|
|
params={"query": "Frankenstein Mary Shelley"},
|
|
timeout=30,
|
|
)
|
|
|
|
if search_resp.status_code == 503:
|
|
pytest.skip("Legacy search source unavailable")
|
|
|
|
if search_resp.status_code != 200:
|
|
pytest.skip(f"Legacy search failed: {search_resp.status_code}")
|
|
|
|
results = search_resp.json()
|
|
if not results:
|
|
pytest.skip("No legacy search results")
|
|
|
|
first_result = results[0]
|
|
book_id = first_result.get("id")
|
|
assert book_id, "Result missing ID"
|
|
|
|
# Get book info
|
|
info_resp = api_client.get("/api/info", params={"id": book_id})
|
|
|
|
if info_resp.status_code != 200:
|
|
pytest.skip(f"Info endpoint failed: {info_resp.status_code}")
|
|
|
|
# Queue download (legacy endpoint)
|
|
download_tracker.track(book_id)
|
|
download_resp = api_client.get("/api/download", params={"id": book_id})
|
|
|
|
if download_resp.status_code != 200:
|
|
pytest.skip(f"Download queue failed: {download_resp.status_code}")
|
|
|
|
download_data = download_resp.json()
|
|
assert download_data.get("status") == "queued"
|
|
|
|
|
|
@pytest.mark.e2e
|
|
class TestDownloadCancellation:
|
|
"""Test download cancellation functionality."""
|
|
|
|
def test_cancel_queued_download(
|
|
self, api_client: APIClient, download_tracker: DownloadTracker
|
|
):
|
|
"""Test cancelling a queued download."""
|
|
# Queue a fake download
|
|
test_id = f"cancel-test-{int(time.time())}"
|
|
download_tracker.track(test_id)
|
|
|
|
queue_resp = api_client.post(
|
|
"/api/releases/download",
|
|
json={
|
|
"source": "test_source",
|
|
"source_id": test_id,
|
|
"title": "Cancel Test Book",
|
|
},
|
|
)
|
|
|
|
if queue_resp.status_code != 200:
|
|
pytest.skip("Could not queue test download")
|
|
|
|
# Give it a moment
|
|
time.sleep(1)
|
|
|
|
# Cancel it
|
|
cancel_resp = api_client.delete(f"/api/download/{test_id}/cancel")
|
|
|
|
assert cancel_resp.status_code in [200, 204]
|
|
|
|
def test_cancel_removes_from_queue(
|
|
self, api_client: APIClient, download_tracker: DownloadTracker
|
|
):
|
|
"""Test that cancellation removes item from queue."""
|
|
test_id = f"cancel-verify-{int(time.time())}"
|
|
download_tracker.track(test_id)
|
|
|
|
# Queue it
|
|
api_client.post(
|
|
"/api/releases/download",
|
|
json={
|
|
"source": "test_source",
|
|
"source_id": test_id,
|
|
"title": "Cancel Verify Test",
|
|
},
|
|
)
|
|
|
|
time.sleep(0.5)
|
|
|
|
# Cancel it
|
|
api_client.delete(f"/api/download/{test_id}/cancel")
|
|
|
|
time.sleep(0.5)
|
|
|
|
# Check it's not in the queue
|
|
queue_resp = api_client.get("/api/queue/order")
|
|
if queue_resp.status_code == 200:
|
|
queue_order = queue_resp.json()
|
|
assert test_id not in queue_order
|
|
|
|
|
|
@pytest.mark.e2e
|
|
class TestQueuePriority:
|
|
"""Test queue priority functionality."""
|
|
|
|
def test_set_priority(
|
|
self, api_client: APIClient, download_tracker: DownloadTracker
|
|
):
|
|
"""Test setting download priority."""
|
|
test_id = f"priority-test-{int(time.time())}"
|
|
download_tracker.track(test_id)
|
|
|
|
# Queue it
|
|
queue_resp = api_client.post(
|
|
"/api/releases/download",
|
|
json={
|
|
"source": "test_source",
|
|
"source_id": test_id,
|
|
"title": "Priority Test",
|
|
"priority": 0,
|
|
},
|
|
)
|
|
|
|
if queue_resp.status_code != 200:
|
|
pytest.skip("Could not queue download")
|
|
|
|
time.sleep(0.5)
|
|
|
|
# Update priority
|
|
priority_resp = api_client.put(
|
|
f"/api/queue/{test_id}/priority",
|
|
json={"priority": 10},
|
|
)
|
|
|
|
# Should succeed or return 404 if already processed
|
|
assert priority_resp.status_code in [200, 404]
|