From 2f229e1d1674040ac3ec81dc10f303c9b1fc234d Mon Sep 17 00:00:00 2001 From: SloppyTurtle Date: Wed, 3 Jun 2026 03:46:44 -0500 Subject: [PATCH 1/6] feat: add watchlist DB schema, API, and tests (issue #1) - Add WatchlistDB class with watchlist_authors and watchlist_releases tables - Follows existing UserDB migration pattern (CREATE IF NOT EXISTS + PRAGMA guards) - Add Flask blueprint with 5 API endpoints for author watch CRUD and release listing - Wire WatchlistDB into main.py startup alongside UserDB - 35 tests covering schema, CRUD, validation, idempotency, and cascade behavior --- shelfmark/main.py | 6 + shelfmark/watchlist/__init__.py | 0 shelfmark/watchlist/db.py | 465 ++++++++++++++++++++++++++ shelfmark/watchlist/routes.py | 200 +++++++++++ tests/watchlist/__init__.py | 0 tests/watchlist/test_watchlist_db.py | 480 +++++++++++++++++++++++++++ 6 files changed, 1151 insertions(+) create mode 100644 shelfmark/watchlist/__init__.py create mode 100644 shelfmark/watchlist/db.py create mode 100644 shelfmark/watchlist/routes.py create mode 100644 tests/watchlist/__init__.py create mode 100644 tests/watchlist/test_watchlist_db.py diff --git a/shelfmark/main.py b/shelfmark/main.py index afee4afd..5606c3c4 100644 --- a/shelfmark/main.py +++ b/shelfmark/main.py @@ -80,6 +80,8 @@ sync_delivery_states_from_queue_status, ) from shelfmark.core.user_db import UserDB +from shelfmark.watchlist.db import WatchlistDB +from shelfmark.watchlist.routes import init_watchlist_routes, watchlist_bp from shelfmark.core.utils import normalize_base_path from shelfmark.download import orchestrator as backend from shelfmark.release_sources import ( @@ -179,6 +181,8 @@ def _raise_runtime_error(message: str) -> NoReturn: user_db.initialize() download_history_service = DownloadHistoryService(_user_db_path) activity_view_state_service = ActivityViewStateService(_user_db_path) + watchlist_db = WatchlistDB(_user_db_path) + watchlist_db.initialize() import_module("shelfmark.config.users_settings") from shelfmark.core.admin_routes import register_admin_routes from shelfmark.core.oidc_routes import register_oidc_routes @@ -187,6 +191,8 @@ def _raise_runtime_error(message: str) -> NoReturn: register_oidc_routes(app, user_db) register_admin_routes(app, user_db) register_self_user_routes(app, user_db) + init_watchlist_routes(watchlist_db) + app.register_blueprint(watchlist_bp) except (sqlite3.OperationalError, OSError) as e: logger.warning( "User database initialization failed: %s. Multi-user authentication features will be disabled. Ensure CONFIG_DIR (%s) exists and is writable.", diff --git a/shelfmark/watchlist/__init__.py b/shelfmark/watchlist/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/shelfmark/watchlist/db.py b/shelfmark/watchlist/db.py new file mode 100644 index 00000000..95617eef --- /dev/null +++ b/shelfmark/watchlist/db.py @@ -0,0 +1,465 @@ +"""Watchlist database for Pulsarr author monitoring.""" + +from __future__ import annotations + +import json +import sqlite3 +import threading +from typing import Any + +from shelfmark.core.logger import setup_logger + +logger = setup_logger(__name__) + +_CREATE_TABLES_SQL = """ +CREATE TABLE IF NOT EXISTS watchlist_authors ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + author_name TEXT NOT NULL, + hardcover_author_id TEXT, + ol_author_key TEXT, + watch_content_types TEXT NOT NULL DEFAULT '["ebook","audiobook"]', + is_active INTEGER NOT NULL DEFAULT 1 CHECK (is_active IN (0, 1)), + deleted_at TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_watchlist_authors_hardcover +ON watchlist_authors (user_id, hardcover_author_id) +WHERE hardcover_author_id IS NOT NULL AND deleted_at IS NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS uq_watchlist_authors_ol +ON watchlist_authors (user_id, ol_author_key) +WHERE ol_author_key IS NOT NULL AND deleted_at IS NULL; + +CREATE INDEX IF NOT EXISTS idx_watchlist_authors_user_active +ON watchlist_authors (user_id, is_active, deleted_at); + +CREATE INDEX IF NOT EXISTS idx_watchlist_authors_hardcover_id +ON watchlist_authors (hardcover_author_id) +WHERE hardcover_author_id IS NOT NULL AND deleted_at IS NULL; + +CREATE INDEX IF NOT EXISTS idx_watchlist_authors_ol_key +ON watchlist_authors (ol_author_key) +WHERE ol_author_key IS NOT NULL AND deleted_at IS NULL; + +CREATE TABLE IF NOT EXISTS watchlist_releases ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + watch_id INTEGER NOT NULL REFERENCES watchlist_authors(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + provider TEXT NOT NULL, + provider_book_id TEXT NOT NULL, + book_data TEXT NOT NULL, + publish_date TEXT, + content_type TEXT NOT NULL, + action_status TEXT NOT NULL DEFAULT 'detected' + CHECK (action_status IN ('detected','queued','skipped','ignored')), + request_id INTEGER REFERENCES download_requests(id) ON DELETE SET NULL, + detected_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + actioned_at TIMESTAMP, + UNIQUE (watch_id, provider_book_id) +); + +CREATE INDEX IF NOT EXISTS idx_watchlist_releases_watch_id +ON watchlist_releases (watch_id, detected_at DESC); + +CREATE INDEX IF NOT EXISTS idx_watchlist_releases_user_calendar +ON watchlist_releases (user_id, publish_date DESC, detected_at DESC); + +CREATE INDEX IF NOT EXISTS idx_watchlist_releases_action_status +ON watchlist_releases (user_id, action_status, detected_at DESC); + +CREATE INDEX IF NOT EXISTS idx_watchlist_releases_pending +ON watchlist_releases (action_status, detected_at DESC) +WHERE action_status = 'detected'; +""" + +_VALID_CONTENT_TYPES: frozenset[str] = frozenset({"ebook", "audiobook"}) +_VALID_ACTION_STATUSES: frozenset[str] = frozenset( + {"detected", "queued", "skipped", "ignored"} +) + + +def _validate_content_types(value: list[str]) -> None: + """Raise ValueError if content type list is invalid.""" + if not value: + msg = "watch_content_types must not be empty" + raise ValueError(msg) + invalid = [v for v in value if v not in _VALID_CONTENT_TYPES] + if invalid: + msg = f"Invalid content types: {invalid}. Must be 'ebook' or 'audiobook'." + raise ValueError(msg) + + +def _serialize_json(value: object, field_name: str) -> str: + """Serialize value to JSON string, raising TypeError on failure.""" + try: + return json.dumps(value) + except (TypeError, ValueError) as e: + msg = f"Failed to serialize {field_name} to JSON" + raise TypeError(msg) from e + + +def _parse_author_row(row: sqlite3.Row | None) -> dict[str, Any] | None: + """Convert a sqlite3.Row to a plain dict, deserializing JSON fields.""" + if row is None: + return None + result = dict(row) + raw_types = result.get("watch_content_types") + if isinstance(raw_types, str): + result["watch_content_types"] = json.loads(raw_types) + return result + + +def _parse_release_row(row: sqlite3.Row | None) -> dict[str, Any] | None: + """Convert a sqlite3.Row to a plain dict, deserializing JSON fields.""" + if row is None: + return None + result = dict(row) + raw_book = result.get("book_data") + if isinstance(raw_book, str): + result["book_data"] = json.loads(raw_book) + return result + + +class WatchlistDB: + """Thread-safe SQLite watchlist database.""" + + def __init__(self, db_path: str) -> None: + """Initialize the watchlist database wrapper for the given SQLite path.""" + self._db_path = db_path + self._lock = threading.Lock() + + def _connect(self) -> sqlite3.Connection: + conn = sqlite3.connect(self._db_path) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys = ON") + return conn + + def initialize(self) -> None: + """Create watchlist tables if they don't exist.""" + with self._lock: + conn = self._connect() + try: + conn.executescript(_CREATE_TABLES_SQL) + conn.commit() + finally: + conn.close() + + # ------------------------------------------------------------------ + # Author watch CRUD + # ------------------------------------------------------------------ + + def add_author( + self, + *, + user_id: int, + author_name: str, + hardcover_author_id: str | None = None, + ol_author_key: str | None = None, + watch_content_types: list[str] | None = None, + ) -> dict[str, Any]: + """Add an author to a user's watchlist. + + At least one of hardcover_author_id or ol_author_key must be provided. + Raises ValueError on invalid input or duplicate watch. + """ + if not author_name or not author_name.strip(): + msg = "author_name is required" + raise ValueError(msg) + if hardcover_author_id is None and ol_author_key is None: + msg = "At least one of hardcover_author_id or ol_author_key must be provided" + raise ValueError(msg) + + content_types = watch_content_types or ["ebook", "audiobook"] + _validate_content_types(content_types) + + with self._lock: + conn = self._connect() + try: + cursor = conn.execute( + """ + INSERT INTO watchlist_authors ( + user_id, author_name, hardcover_author_id, ol_author_key, + watch_content_types + ) VALUES (?, ?, ?, ?, ?) + """, + ( + user_id, + author_name.strip(), + hardcover_author_id, + ol_author_key, + _serialize_json(content_types, "watch_content_types"), + ), + ) + conn.commit() + row = conn.execute( + "SELECT * FROM watchlist_authors WHERE id = ?", + (cursor.lastrowid,), + ).fetchone() + result = _parse_author_row(row) + if result is None: + msg = "Failed to load newly created watch entry" + raise RuntimeError(msg) + return result + except sqlite3.IntegrityError as e: + msg = f"Watch entry already exists: {e}" + raise ValueError(msg) from e + finally: + conn.close() + + def get_author(self, watch_id: int) -> dict[str, Any] | None: + """Return a single watch entry by ID, or None if not found.""" + conn = self._connect() + try: + row = conn.execute( + "SELECT * FROM watchlist_authors WHERE id = ? AND deleted_at IS NULL", + (watch_id,), + ).fetchone() + return _parse_author_row(row) + finally: + conn.close() + + def list_authors( + self, + user_id: int, + *, + include_inactive: bool = False, + ) -> list[dict[str, Any]]: + """Return all non-deleted watch entries for a user.""" + query = """ + SELECT * FROM watchlist_authors + WHERE user_id = ? AND deleted_at IS NULL + """ + params: list[Any] = [user_id] + if not include_inactive: + query += " AND is_active = 1" + query += " ORDER BY author_name ASC" + + conn = self._connect() + try: + rows = conn.execute(query, params).fetchall() + return [r for row in rows if (r := _parse_author_row(row)) is not None] + finally: + conn.close() + + def list_all_active_authors(self) -> list[dict[str, Any]]: + """Return all active, non-deleted watch entries across all users. + + Used by the scheduler to know what to check. + """ + conn = self._connect() + try: + rows = conn.execute( + """ + SELECT * FROM watchlist_authors + WHERE is_active = 1 AND deleted_at IS NULL + ORDER BY user_id ASC, author_name ASC + """, + ).fetchall() + return [r for row in rows if (r := _parse_author_row(row)) is not None] + finally: + conn.close() + + def update_author( + self, + watch_id: int, + *, + is_active: bool | None = None, + watch_content_types: list[str] | None = None, + author_name: str | None = None, + ) -> dict[str, Any] | None: + """Update mutable fields on a watch entry. Returns updated row or None.""" + if watch_content_types is not None: + _validate_content_types(watch_content_types) + + with self._lock: + conn = self._connect() + try: + if is_active is not None: + conn.execute( + """ + UPDATE watchlist_authors + SET is_active = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? AND deleted_at IS NULL + """, + (1 if is_active else 0, watch_id), + ) + if watch_content_types is not None: + conn.execute( + """ + UPDATE watchlist_authors + SET watch_content_types = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? AND deleted_at IS NULL + """, + ( + _serialize_json(watch_content_types, "watch_content_types"), + watch_id, + ), + ) + if author_name is not None: + if not author_name.strip(): + msg = "author_name must not be blank" + raise ValueError(msg) + conn.execute( + """ + UPDATE watchlist_authors + SET author_name = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? AND deleted_at IS NULL + """, + (author_name.strip(), watch_id), + ) + conn.commit() + row = conn.execute( + "SELECT * FROM watchlist_authors WHERE id = ?", + (watch_id,), + ).fetchone() + return _parse_author_row(row) + finally: + conn.close() + + def remove_author(self, watch_id: int) -> bool: + """Soft-delete a watch entry. Returns True if a row was affected.""" + with self._lock: + conn = self._connect() + try: + cursor = conn.execute( + """ + UPDATE watchlist_authors + SET deleted_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP + WHERE id = ? AND deleted_at IS NULL + """, + (watch_id,), + ) + conn.commit() + return cursor.rowcount > 0 + finally: + conn.close() + + # ------------------------------------------------------------------ + # Release CRUD + # ------------------------------------------------------------------ + + def upsert_release( + self, + *, + watch_id: int, + user_id: int, + provider: str, + provider_book_id: str, + book_data: dict[str, Any], + content_type: str, + publish_date: str | None = None, + ) -> dict[str, Any]: + """Insert a detected release, or return the existing row if already known. + + Idempotent: safe to call repeatedly from the scheduler. + Raises ValueError on invalid input. + """ + if content_type not in _VALID_CONTENT_TYPES: + msg = f"Invalid content_type: {content_type!r}" + raise ValueError(msg) + if not book_data: + msg = "book_data must not be empty" + raise ValueError(msg) + + with self._lock: + conn = self._connect() + try: + conn.execute( + """ + INSERT OR IGNORE INTO watchlist_releases ( + watch_id, user_id, provider, provider_book_id, + book_data, content_type, publish_date + ) VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + watch_id, + user_id, + provider, + provider_book_id, + _serialize_json(book_data, "book_data"), + content_type, + publish_date, + ), + ) + conn.commit() + row = conn.execute( + """ + SELECT * FROM watchlist_releases + WHERE watch_id = ? AND provider_book_id = ? + """, + (watch_id, provider_book_id), + ).fetchone() + result = _parse_release_row(row) + if result is None: + msg = "Failed to load release row after upsert" + raise RuntimeError(msg) + return result + finally: + conn.close() + + def list_releases( + self, + user_id: int, + *, + action_status: str | None = None, + limit: int = 50, + offset: int = 0, + ) -> list[dict[str, Any]]: + """Return detected releases for a user, newest first.""" + query = """ + SELECT * FROM watchlist_releases + WHERE user_id = ? + """ + params: list[Any] = [user_id] + if action_status is not None: + if action_status not in _VALID_ACTION_STATUSES: + msg = f"Invalid action_status: {action_status!r}" + raise ValueError(msg) + query += " AND action_status = ?" + params.append(action_status) + query += " ORDER BY detected_at DESC LIMIT ? OFFSET ?" + params.extend([limit, offset]) + + conn = self._connect() + try: + rows = conn.execute(query, params).fetchall() + return [r for row in rows if (r := _parse_release_row(row)) is not None] + finally: + conn.close() + + def update_release_action( + self, + release_id: int, + *, + action_status: str, + request_id: int | None = None, + ) -> dict[str, Any] | None: + """Update the action status on a release (e.g. after auto-queuing).""" + if action_status not in _VALID_ACTION_STATUSES: + msg = f"Invalid action_status: {action_status!r}" + raise ValueError(msg) + + with self._lock: + conn = self._connect() + try: + conn.execute( + """ + UPDATE watchlist_releases + SET action_status = ?, + request_id = COALESCE(?, request_id), + actioned_at = CURRENT_TIMESTAMP + WHERE id = ? + """, + (action_status, request_id, release_id), + ) + conn.commit() + row = conn.execute( + "SELECT * FROM watchlist_releases WHERE id = ?", + (release_id,), + ).fetchone() + return _parse_release_row(row) + finally: + conn.close() diff --git a/shelfmark/watchlist/routes.py b/shelfmark/watchlist/routes.py new file mode 100644 index 00000000..a65d1aa7 --- /dev/null +++ b/shelfmark/watchlist/routes.py @@ -0,0 +1,200 @@ +"""Flask blueprint for Pulsarr watchlist API endpoints.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from flask import Blueprint, jsonify, request + +from shelfmark.core.logger import setup_logger + +if TYPE_CHECKING: + from shelfmark.watchlist.db import WatchlistDB + +logger = setup_logger(__name__) + +watchlist_bp = Blueprint("watchlist", __name__, url_prefix="/api/watchlist") + +# Populated by init_watchlist_routes() +_watchlist_db: WatchlistDB | None = None + + +def init_watchlist_routes(watchlist_db: WatchlistDB) -> None: + """Bind the WatchlistDB instance used by route handlers.""" + global _watchlist_db # noqa: PLW0603 + _watchlist_db = watchlist_db + + +def _get_db() -> WatchlistDB: + if _watchlist_db is None: + msg = "WatchlistDB not initialized" + raise RuntimeError(msg) + return _watchlist_db + + +def _get_current_user_id() -> int | None: + """Return the authenticated user's DB ID from the Flask session. + + Mirrors the pattern used in existing Shelfmark route handlers. + """ + from flask import session + raw = session.get("db_user_id") + if raw is None: + return None + try: + return int(raw) + except (TypeError, ValueError): + return None + + +def _error(message: str, status: int = 400) -> Any: + return jsonify({"error": message}), status + + +# ------------------------------------------------------------------ +# Author watch endpoints +# ------------------------------------------------------------------ + +@watchlist_bp.get("/authors") +def list_authors() -> Any: + """GET /api/watchlist/authors — list watched authors for current user.""" + user_id = _get_current_user_id() + if user_id is None: + return _error("Not authenticated", 401) + + include_inactive = request.args.get("include_inactive", "false").lower() == "true" + + authors = _get_db().list_authors(user_id, include_inactive=include_inactive) + return jsonify(authors) + + +@watchlist_bp.post("/authors") +def add_author() -> Any: + """POST /api/watchlist/authors — add an author to the watchlist.""" + user_id = _get_current_user_id() + if user_id is None: + return _error("Not authenticated", 401) + + body = request.get_json(silent=True) + if not body: + return _error("Request body must be JSON") + + author_name = body.get("author_name", "") + hardcover_author_id = body.get("hardcover_author_id") or None + ol_author_key = body.get("ol_author_key") or None + watch_content_types = body.get("watch_content_types") + + if not author_name: + return _error("author_name is required") + if hardcover_author_id is None and ol_author_key is None: + return _error("At least one of hardcover_author_id or ol_author_key is required") + + if watch_content_types is not None and not isinstance(watch_content_types, list): + return _error("watch_content_types must be an array") + + try: + entry = _get_db().add_author( + user_id=user_id, + author_name=author_name, + hardcover_author_id=hardcover_author_id, + ol_author_key=ol_author_key, + watch_content_types=watch_content_types, + ) + except ValueError as e: + return _error(str(e)) + + return jsonify(entry), 201 + + +@watchlist_bp.delete("/authors/") +def remove_author(watch_id: int) -> Any: + """DELETE /api/watchlist/authors/ — remove an author from the watchlist.""" + user_id = _get_current_user_id() + if user_id is None: + return _error("Not authenticated", 401) + + entry = _get_db().get_author(watch_id) + if entry is None: + return _error("Watch entry not found", 404) + if entry["user_id"] != user_id: + return _error("Forbidden", 403) + + _get_db().remove_author(watch_id) + return jsonify({"deleted": True, "id": watch_id}) + + +@watchlist_bp.patch("/authors/") +def update_author(watch_id: int) -> Any: + """PATCH /api/watchlist/authors/ — update is_active or watch_content_types.""" + user_id = _get_current_user_id() + if user_id is None: + return _error("Not authenticated", 401) + + entry = _get_db().get_author(watch_id) + if entry is None: + return _error("Watch entry not found", 404) + if entry["user_id"] != user_id: + return _error("Forbidden", 403) + + body = request.get_json(silent=True) + if not body: + return _error("Request body must be JSON") + + is_active = body.get("is_active") + watch_content_types = body.get("watch_content_types") + author_name = body.get("author_name") + + if is_active is not None and not isinstance(is_active, bool): + return _error("is_active must be a boolean") + if watch_content_types is not None and not isinstance(watch_content_types, list): + return _error("watch_content_types must be an array") + + try: + updated = _get_db().update_author( + watch_id, + is_active=is_active, + watch_content_types=watch_content_types, + author_name=author_name, + ) + except ValueError as e: + return _error(str(e)) + + if updated is None: + return _error("Watch entry not found", 404) + + return jsonify(updated) + + +# ------------------------------------------------------------------ +# Release endpoints +# ------------------------------------------------------------------ + +@watchlist_bp.get("/releases") +def list_releases() -> Any: + """GET /api/watchlist/releases — list detected releases for current user.""" + user_id = _get_current_user_id() + if user_id is None: + return _error("Not authenticated", 401) + + action_status = request.args.get("action_status") or None + + try: + limit = int(request.args.get("limit", 50)) + offset = int(request.args.get("offset", 0)) + except ValueError: + return _error("limit and offset must be integers") + + limit = min(max(limit, 1), 200) + offset = max(offset, 0) + + try: + releases = _get_db().list_releases( + user_id, + action_status=action_status, + limit=limit, + offset=offset, + ) + except ValueError as e: + return _error(str(e)) + + return jsonify(releases) diff --git a/tests/watchlist/__init__.py b/tests/watchlist/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/watchlist/test_watchlist_db.py b/tests/watchlist/test_watchlist_db.py new file mode 100644 index 00000000..ad6805c6 --- /dev/null +++ b/tests/watchlist/test_watchlist_db.py @@ -0,0 +1,480 @@ +"""Tests for WatchlistDB — schema creation, CRUD, and idempotency.""" + +import json +import os +import sqlite3 +import tempfile + +import pytest + + +@pytest.fixture +def db_path(): + """Temporary SQLite database path.""" + with tempfile.TemporaryDirectory() as tmpdir: + yield os.path.join(tmpdir, "test.db") + + +@pytest.fixture +def raw_conn(db_path): + """Raw sqlite3 connection for schema inspection.""" + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + yield conn + conn.close() + + +@pytest.fixture +def watchlist_db(db_path): + """Initialized WatchlistDB instance.""" + from shelfmark.watchlist.db import WatchlistDB + + db = WatchlistDB(db_path) + db.initialize() + return db + + +@pytest.fixture +def user_db(db_path): + """Initialized UserDB instance sharing the same DB file.""" + from shelfmark.core.user_db import UserDB + + db = UserDB(db_path) + db.initialize() + return db + + +@pytest.fixture +def full_db(db_path): + """Both UserDB and WatchlistDB initialized on the same file.""" + from shelfmark.core.user_db import UserDB + from shelfmark.watchlist.db import WatchlistDB + + udb = UserDB(db_path) + udb.initialize() + wdb = WatchlistDB(db_path) + wdb.initialize() + return udb, wdb + + +@pytest.fixture +def test_user(full_db): + """A real user row for FK tests.""" + udb, wdb = full_db + user = udb.create_user(username="testuser", password_hash="hash") + return user, wdb + + +# ------------------------------------------------------------------ +# Schema creation +# ------------------------------------------------------------------ + +class TestSchema: + def test_creates_watchlist_authors_table(self, watchlist_db, db_path): + conn = sqlite3.connect(db_path) + row = conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='watchlist_authors'" + ).fetchone() + conn.close() + assert row is not None + + def test_creates_watchlist_releases_table(self, watchlist_db, db_path): + conn = sqlite3.connect(db_path) + row = conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='watchlist_releases'" + ).fetchone() + conn.close() + assert row is not None + + def test_initialize_is_idempotent(self, db_path): + from shelfmark.watchlist.db import WatchlistDB + + db = WatchlistDB(db_path) + db.initialize() + db.initialize() # should not raise + + def test_authors_table_has_expected_columns(self, watchlist_db, db_path): + conn = sqlite3.connect(db_path) + cols = {row[1] for row in conn.execute("PRAGMA table_info(watchlist_authors)")} + conn.close() + expected = { + "id", "user_id", "author_name", "hardcover_author_id", "ol_author_key", + "watch_content_types", "is_active", "deleted_at", "created_at", "updated_at", + } + assert expected <= cols + + def test_releases_table_has_expected_columns(self, watchlist_db, db_path): + conn = sqlite3.connect(db_path) + cols = {row[1] for row in conn.execute("PRAGMA table_info(watchlist_releases)")} + conn.close() + expected = { + "id", "watch_id", "user_id", "provider", "provider_book_id", + "book_data", "publish_date", "content_type", "action_status", + "request_id", "detected_at", "actioned_at", + } + assert expected <= cols + + +# ------------------------------------------------------------------ +# add_author +# ------------------------------------------------------------------ + +class TestAddAuthor: + def test_add_author_hardcover(self, test_user): + user, wdb = test_user + entry = wdb.add_author( + user_id=user["id"], + author_name="Brandon Sanderson", + hardcover_author_id="12345", + ) + assert entry["author_name"] == "Brandon Sanderson" + assert entry["hardcover_author_id"] == "12345" + assert entry["is_active"] == 1 + assert entry["deleted_at"] is None + + def test_add_author_ol(self, test_user): + user, wdb = test_user + entry = wdb.add_author( + user_id=user["id"], + author_name="Ursula K. Le Guin", + ol_author_key="/authors/OL18211A", + ) + assert entry["ol_author_key"] == "/authors/OL18211A" + + def test_add_author_both_providers(self, test_user): + user, wdb = test_user + entry = wdb.add_author( + user_id=user["id"], + author_name="Terry Pratchett", + hardcover_author_id="99", + ol_author_key="/authors/OL1A", + ) + assert entry["hardcover_author_id"] == "99" + assert entry["ol_author_key"] == "/authors/OL1A" + + def test_add_author_default_content_types(self, test_user): + user, wdb = test_user + entry = wdb.add_author( + user_id=user["id"], + author_name="N.K. Jemisin", + hardcover_author_id="777", + ) + assert set(entry["watch_content_types"]) == {"ebook", "audiobook"} + + def test_add_author_custom_content_types(self, test_user): + user, wdb = test_user + entry = wdb.add_author( + user_id=user["id"], + author_name="N.K. Jemisin", + hardcover_author_id="777", + watch_content_types=["ebook"], + ) + assert entry["watch_content_types"] == ["ebook"] + + def test_add_author_requires_name(self, test_user): + user, wdb = test_user + with pytest.raises(ValueError, match="author_name"): + wdb.add_author(user_id=user["id"], author_name="", hardcover_author_id="1") + + def test_add_author_requires_at_least_one_provider_key(self, test_user): + user, wdb = test_user + with pytest.raises(ValueError, match="hardcover_author_id or ol_author_key"): + wdb.add_author(user_id=user["id"], author_name="Nobody") + + def test_add_author_rejects_invalid_content_type(self, test_user): + user, wdb = test_user + with pytest.raises(ValueError, match="Invalid content types"): + wdb.add_author( + user_id=user["id"], + author_name="Bad", + hardcover_author_id="1", + watch_content_types=["magazine"], + ) + + def test_duplicate_hardcover_id_raises(self, test_user): + user, wdb = test_user + wdb.add_author( + user_id=user["id"], author_name="Author A", hardcover_author_id="42" + ) + with pytest.raises(ValueError, match="already exists"): + wdb.add_author( + user_id=user["id"], author_name="Author A Again", hardcover_author_id="42" + ) + + def test_duplicate_ol_key_raises(self, test_user): + user, wdb = test_user + wdb.add_author( + user_id=user["id"], author_name="Author B", ol_author_key="/authors/OL1A" + ) + with pytest.raises(ValueError, match="already exists"): + wdb.add_author( + user_id=user["id"], author_name="Author B Again", ol_author_key="/authors/OL1A" + ) + + +# ------------------------------------------------------------------ +# get_author / list_authors +# ------------------------------------------------------------------ + +class TestGetListAuthors: + def test_get_author_returns_entry(self, test_user): + user, wdb = test_user + entry = wdb.add_author( + user_id=user["id"], author_name="Ann Leckie", hardcover_author_id="55" + ) + fetched = wdb.get_author(entry["id"]) + assert fetched is not None + assert fetched["id"] == entry["id"] + + def test_get_author_returns_none_for_missing(self, watchlist_db): + assert watchlist_db.get_author(99999) is None + + def test_list_authors_returns_active_only_by_default(self, test_user): + user, wdb = test_user + e1 = wdb.add_author( + user_id=user["id"], author_name="Active Author", hardcover_author_id="1" + ) + e2 = wdb.add_author( + user_id=user["id"], author_name="Inactive Author", hardcover_author_id="2" + ) + wdb.update_author(e2["id"], is_active=False) + authors = wdb.list_authors(user["id"]) + ids = [a["id"] for a in authors] + assert e1["id"] in ids + assert e2["id"] not in ids + + def test_list_authors_include_inactive(self, test_user): + user, wdb = test_user + e = wdb.add_author( + user_id=user["id"], author_name="Inactive", hardcover_author_id="3" + ) + wdb.update_author(e["id"], is_active=False) + authors = wdb.list_authors(user["id"], include_inactive=True) + assert any(a["id"] == e["id"] for a in authors) + + def test_list_authors_excludes_deleted(self, test_user): + user, wdb = test_user + e = wdb.add_author( + user_id=user["id"], author_name="Deleted Author", hardcover_author_id="4" + ) + wdb.remove_author(e["id"]) + authors = wdb.list_authors(user["id"], include_inactive=True) + assert not any(a["id"] == e["id"] for a in authors) + + +# ------------------------------------------------------------------ +# update_author / remove_author +# ------------------------------------------------------------------ + +class TestUpdateRemoveAuthor: + def test_toggle_inactive(self, test_user): + user, wdb = test_user + e = wdb.add_author( + user_id=user["id"], author_name="Toggle Me", hardcover_author_id="10" + ) + updated = wdb.update_author(e["id"], is_active=False) + assert updated is not None + assert updated["is_active"] == 0 + + def test_update_content_types(self, test_user): + user, wdb = test_user + e = wdb.add_author( + user_id=user["id"], author_name="Update Types", hardcover_author_id="11" + ) + updated = wdb.update_author(e["id"], watch_content_types=["audiobook"]) + assert updated is not None + assert updated["watch_content_types"] == ["audiobook"] + + def test_update_rejects_invalid_content_type(self, test_user): + user, wdb = test_user + e = wdb.add_author( + user_id=user["id"], author_name="Bad Update", hardcover_author_id="12" + ) + with pytest.raises(ValueError, match="Invalid content types"): + wdb.update_author(e["id"], watch_content_types=["comic"]) + + def test_remove_author_soft_deletes(self, test_user): + user, wdb = test_user + e = wdb.add_author( + user_id=user["id"], author_name="Remove Me", hardcover_author_id="20" + ) + result = wdb.remove_author(e["id"]) + assert result is True + assert wdb.get_author(e["id"]) is None + + def test_remove_author_returns_false_when_not_found(self, watchlist_db): + assert watchlist_db.remove_author(99999) is False + + def test_remove_author_is_idempotent(self, test_user): + user, wdb = test_user + e = wdb.add_author( + user_id=user["id"], author_name="Remove Twice", hardcover_author_id="21" + ) + wdb.remove_author(e["id"]) + result = wdb.remove_author(e["id"]) + assert result is False + + +# ------------------------------------------------------------------ +# upsert_release +# ------------------------------------------------------------------ + +class TestUpsertRelease: + def _sample_book_data(self) -> dict: + return { + "provider": "hardcover", + "provider_id": "hc-book-1", + "title": "The Way of Kings", + "authors": ["Brandon Sanderson"], + "publish_year": 2010, + } + + def test_upsert_creates_release(self, test_user): + user, wdb = test_user + entry = wdb.add_author( + user_id=user["id"], author_name="Sanderson", hardcover_author_id="99" + ) + release = wdb.upsert_release( + watch_id=entry["id"], + user_id=user["id"], + provider="hardcover", + provider_book_id="hc-book-1", + book_data=self._sample_book_data(), + content_type="ebook", + publish_date="2010-08-31", + ) + assert release["provider_book_id"] == "hc-book-1" + assert release["action_status"] == "detected" + assert release["book_data"]["title"] == "The Way of Kings" + + def test_upsert_is_idempotent(self, test_user): + user, wdb = test_user + entry = wdb.add_author( + user_id=user["id"], author_name="Sanderson", hardcover_author_id="99" + ) + r1 = wdb.upsert_release( + watch_id=entry["id"], + user_id=user["id"], + provider="hardcover", + provider_book_id="hc-book-1", + book_data=self._sample_book_data(), + content_type="ebook", + ) + r2 = wdb.upsert_release( + watch_id=entry["id"], + user_id=user["id"], + provider="hardcover", + provider_book_id="hc-book-1", + book_data=self._sample_book_data(), + content_type="ebook", + ) + assert r1["id"] == r2["id"] + + def test_upsert_rejects_invalid_content_type(self, test_user): + user, wdb = test_user + entry = wdb.add_author( + user_id=user["id"], author_name="Author", hardcover_author_id="100" + ) + with pytest.raises(ValueError, match="content_type"): + wdb.upsert_release( + watch_id=entry["id"], + user_id=user["id"], + provider="hardcover", + provider_book_id="bad-1", + book_data=self._sample_book_data(), + content_type="magazine", + ) + + def test_upsert_rejects_empty_book_data(self, test_user): + user, wdb = test_user + entry = wdb.add_author( + user_id=user["id"], author_name="Author", hardcover_author_id="101" + ) + with pytest.raises(ValueError, match="book_data"): + wdb.upsert_release( + watch_id=entry["id"], + user_id=user["id"], + provider="hardcover", + provider_book_id="bad-2", + book_data={}, + content_type="ebook", + ) + + +# ------------------------------------------------------------------ +# list_releases / update_release_action +# ------------------------------------------------------------------ + +class TestReleasesQueryAndUpdate: + def _add_release(self, wdb, watch_id, user_id, book_id, content_type="ebook"): + return wdb.upsert_release( + watch_id=watch_id, + user_id=user_id, + provider="hardcover", + provider_book_id=book_id, + book_data={"title": f"Book {book_id}", "provider": "hardcover", "provider_id": book_id}, + content_type=content_type, + ) + + def test_list_releases_returns_user_releases(self, test_user): + user, wdb = test_user + entry = wdb.add_author( + user_id=user["id"], author_name="Author", hardcover_author_id="200" + ) + self._add_release(wdb, entry["id"], user["id"], "book-a") + self._add_release(wdb, entry["id"], user["id"], "book-b") + releases = wdb.list_releases(user["id"]) + assert len(releases) == 2 + + def test_list_releases_filter_by_status(self, test_user): + user, wdb = test_user + entry = wdb.add_author( + user_id=user["id"], author_name="Author", hardcover_author_id="201" + ) + r = self._add_release(wdb, entry["id"], user["id"], "book-c") + wdb.update_release_action(r["id"], action_status="queued") + detected = wdb.list_releases(user["id"], action_status="detected") + queued = wdb.list_releases(user["id"], action_status="queued") + assert len(detected) == 0 + assert len(queued) == 1 + + def test_update_release_action_sets_status(self, test_user): + user, wdb = test_user + entry = wdb.add_author( + user_id=user["id"], author_name="Author", hardcover_author_id="202" + ) + r = self._add_release(wdb, entry["id"], user["id"], "book-d") + updated = wdb.update_release_action(r["id"], action_status="skipped") + assert updated is not None + assert updated["action_status"] == "skipped" + assert updated["actioned_at"] is not None + + def test_update_release_action_rejects_invalid_status(self, test_user): + user, wdb = test_user + entry = wdb.add_author( + user_id=user["id"], author_name="Author", hardcover_author_id="203" + ) + r = self._add_release(wdb, entry["id"], user["id"], "book-e") + with pytest.raises(ValueError, match="action_status"): + wdb.update_release_action(r["id"], action_status="purchased") + + +# ------------------------------------------------------------------ +# Cascade on user delete +# ------------------------------------------------------------------ + +class TestCascade: + def test_delete_user_cascades_to_authors(self, full_db, db_path): + udb, wdb = full_db + user = udb.create_user(username="cascade_user", password_hash="x") + wdb.add_author( + user_id=user["id"], author_name="Cascade Author", hardcover_author_id="999" + ) + udb.delete_user(user["id"]) + conn = sqlite3.connect(db_path) + conn.execute("PRAGMA foreign_keys = ON") + rows = conn.execute( + "SELECT * FROM watchlist_authors WHERE user_id = ?", (user["id"],) + ).fetchall() + conn.close() + assert len(rows) == 0 From 23b55802cd3d32df3e37df5f853f889b8df267f3 Mon Sep 17 00:00:00 2001 From: spin-drift Date: Sun, 14 Jun 2026 00:56:15 -0400 Subject: [PATCH 2/6] =?UTF-8?q?style:=20ruff=20fixes=20and=20Pulsarr?= =?UTF-8?q?=E2=86=92Shelfmark=20docstring=20revert=20on=20watchlist=20port?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auto-fixed: import sorting in main.py, unused noqa in routes.py, unused json import in test_watchlist_db.py. Manual fixes: TRY300 (return moved to else-branch in db.py add_author), and ruff format on the three files that drifted from this repo's config. Also reverted "Pulsarr" → "Shelfmark" in two module docstrings to keep this fork's naming consistent. All 1977 backend tests pass (35 new from this port). --- shelfmark/main.py | 4 +- shelfmark/watchlist/db.py | 9 ++- shelfmark/watchlist/routes.py | 9 ++- tests/watchlist/test_watchlist_db.py | 95 +++++++++++++--------------- 4 files changed, 56 insertions(+), 61 deletions(-) diff --git a/shelfmark/main.py b/shelfmark/main.py index 5606c3c4..901f2e7c 100644 --- a/shelfmark/main.py +++ b/shelfmark/main.py @@ -80,8 +80,6 @@ sync_delivery_states_from_queue_status, ) from shelfmark.core.user_db import UserDB -from shelfmark.watchlist.db import WatchlistDB -from shelfmark.watchlist.routes import init_watchlist_routes, watchlist_bp from shelfmark.core.utils import normalize_base_path from shelfmark.download import orchestrator as backend from shelfmark.release_sources import ( @@ -90,6 +88,8 @@ SourceUnavailableError, get_source_display_name, ) +from shelfmark.watchlist.db import WatchlistDB +from shelfmark.watchlist.routes import init_watchlist_routes, watchlist_bp if TYPE_CHECKING: from shelfmark.metadata_providers import BookMetadata, MetadataProvider diff --git a/shelfmark/watchlist/db.py b/shelfmark/watchlist/db.py index 95617eef..a7923f28 100644 --- a/shelfmark/watchlist/db.py +++ b/shelfmark/watchlist/db.py @@ -1,4 +1,4 @@ -"""Watchlist database for Pulsarr author monitoring.""" +"""Watchlist database for Shelfmark author monitoring.""" from __future__ import annotations @@ -76,9 +76,7 @@ """ _VALID_CONTENT_TYPES: frozenset[str] = frozenset({"ebook", "audiobook"}) -_VALID_ACTION_STATUSES: frozenset[str] = frozenset( - {"detected", "queued", "skipped", "ignored"} -) +_VALID_ACTION_STATUSES: frozenset[str] = frozenset({"detected", "queued", "skipped", "ignored"}) def _validate_content_types(value: list[str]) -> None: @@ -202,10 +200,11 @@ def add_author( if result is None: msg = "Failed to load newly created watch entry" raise RuntimeError(msg) - return result except sqlite3.IntegrityError as e: msg = f"Watch entry already exists: {e}" raise ValueError(msg) from e + else: + return result finally: conn.close() diff --git a/shelfmark/watchlist/routes.py b/shelfmark/watchlist/routes.py index a65d1aa7..c535f5eb 100644 --- a/shelfmark/watchlist/routes.py +++ b/shelfmark/watchlist/routes.py @@ -1,4 +1,4 @@ -"""Flask blueprint for Pulsarr watchlist API endpoints.""" +"""Flask blueprint for Shelfmark watchlist API endpoints.""" from __future__ import annotations @@ -21,7 +21,7 @@ def init_watchlist_routes(watchlist_db: WatchlistDB) -> None: """Bind the WatchlistDB instance used by route handlers.""" - global _watchlist_db # noqa: PLW0603 + global _watchlist_db _watchlist_db = watchlist_db @@ -38,12 +38,13 @@ def _get_current_user_id() -> int | None: Mirrors the pattern used in existing Shelfmark route handlers. """ from flask import session + raw = session.get("db_user_id") if raw is None: return None try: return int(raw) - except (TypeError, ValueError): + except TypeError, ValueError: return None @@ -55,6 +56,7 @@ def _error(message: str, status: int = 400) -> Any: # Author watch endpoints # ------------------------------------------------------------------ + @watchlist_bp.get("/authors") def list_authors() -> Any: """GET /api/watchlist/authors — list watched authors for current user.""" @@ -169,6 +171,7 @@ def update_author(watch_id: int) -> Any: # Release endpoints # ------------------------------------------------------------------ + @watchlist_bp.get("/releases") def list_releases() -> Any: """GET /api/watchlist/releases — list detected releases for current user.""" diff --git a/tests/watchlist/test_watchlist_db.py b/tests/watchlist/test_watchlist_db.py index ad6805c6..e946c5af 100644 --- a/tests/watchlist/test_watchlist_db.py +++ b/tests/watchlist/test_watchlist_db.py @@ -1,6 +1,5 @@ """Tests for WatchlistDB — schema creation, CRUD, and idempotency.""" -import json import os import sqlite3 import tempfile @@ -69,6 +68,7 @@ def test_user(full_db): # Schema creation # ------------------------------------------------------------------ + class TestSchema: def test_creates_watchlist_authors_table(self, watchlist_db, db_path): conn = sqlite3.connect(db_path) @@ -98,8 +98,16 @@ def test_authors_table_has_expected_columns(self, watchlist_db, db_path): cols = {row[1] for row in conn.execute("PRAGMA table_info(watchlist_authors)")} conn.close() expected = { - "id", "user_id", "author_name", "hardcover_author_id", "ol_author_key", - "watch_content_types", "is_active", "deleted_at", "created_at", "updated_at", + "id", + "user_id", + "author_name", + "hardcover_author_id", + "ol_author_key", + "watch_content_types", + "is_active", + "deleted_at", + "created_at", + "updated_at", } assert expected <= cols @@ -108,9 +116,18 @@ def test_releases_table_has_expected_columns(self, watchlist_db, db_path): cols = {row[1] for row in conn.execute("PRAGMA table_info(watchlist_releases)")} conn.close() expected = { - "id", "watch_id", "user_id", "provider", "provider_book_id", - "book_data", "publish_date", "content_type", "action_status", - "request_id", "detected_at", "actioned_at", + "id", + "watch_id", + "user_id", + "provider", + "provider_book_id", + "book_data", + "publish_date", + "content_type", + "action_status", + "request_id", + "detected_at", + "actioned_at", } assert expected <= cols @@ -119,6 +136,7 @@ def test_releases_table_has_expected_columns(self, watchlist_db, db_path): # add_author # ------------------------------------------------------------------ + class TestAddAuthor: def test_add_author_hardcover(self, test_user): user, wdb = test_user @@ -193,9 +211,7 @@ def test_add_author_rejects_invalid_content_type(self, test_user): def test_duplicate_hardcover_id_raises(self, test_user): user, wdb = test_user - wdb.add_author( - user_id=user["id"], author_name="Author A", hardcover_author_id="42" - ) + wdb.add_author(user_id=user["id"], author_name="Author A", hardcover_author_id="42") with pytest.raises(ValueError, match="already exists"): wdb.add_author( user_id=user["id"], author_name="Author A Again", hardcover_author_id="42" @@ -203,9 +219,7 @@ def test_duplicate_hardcover_id_raises(self, test_user): def test_duplicate_ol_key_raises(self, test_user): user, wdb = test_user - wdb.add_author( - user_id=user["id"], author_name="Author B", ol_author_key="/authors/OL1A" - ) + wdb.add_author(user_id=user["id"], author_name="Author B", ol_author_key="/authors/OL1A") with pytest.raises(ValueError, match="already exists"): wdb.add_author( user_id=user["id"], author_name="Author B Again", ol_author_key="/authors/OL1A" @@ -216,6 +230,7 @@ def test_duplicate_ol_key_raises(self, test_user): # get_author / list_authors # ------------------------------------------------------------------ + class TestGetListAuthors: def test_get_author_returns_entry(self, test_user): user, wdb = test_user @@ -245,9 +260,7 @@ def test_list_authors_returns_active_only_by_default(self, test_user): def test_list_authors_include_inactive(self, test_user): user, wdb = test_user - e = wdb.add_author( - user_id=user["id"], author_name="Inactive", hardcover_author_id="3" - ) + e = wdb.add_author(user_id=user["id"], author_name="Inactive", hardcover_author_id="3") wdb.update_author(e["id"], is_active=False) authors = wdb.list_authors(user["id"], include_inactive=True) assert any(a["id"] == e["id"] for a in authors) @@ -266,38 +279,31 @@ def test_list_authors_excludes_deleted(self, test_user): # update_author / remove_author # ------------------------------------------------------------------ + class TestUpdateRemoveAuthor: def test_toggle_inactive(self, test_user): user, wdb = test_user - e = wdb.add_author( - user_id=user["id"], author_name="Toggle Me", hardcover_author_id="10" - ) + e = wdb.add_author(user_id=user["id"], author_name="Toggle Me", hardcover_author_id="10") updated = wdb.update_author(e["id"], is_active=False) assert updated is not None assert updated["is_active"] == 0 def test_update_content_types(self, test_user): user, wdb = test_user - e = wdb.add_author( - user_id=user["id"], author_name="Update Types", hardcover_author_id="11" - ) + e = wdb.add_author(user_id=user["id"], author_name="Update Types", hardcover_author_id="11") updated = wdb.update_author(e["id"], watch_content_types=["audiobook"]) assert updated is not None assert updated["watch_content_types"] == ["audiobook"] def test_update_rejects_invalid_content_type(self, test_user): user, wdb = test_user - e = wdb.add_author( - user_id=user["id"], author_name="Bad Update", hardcover_author_id="12" - ) + e = wdb.add_author(user_id=user["id"], author_name="Bad Update", hardcover_author_id="12") with pytest.raises(ValueError, match="Invalid content types"): wdb.update_author(e["id"], watch_content_types=["comic"]) def test_remove_author_soft_deletes(self, test_user): user, wdb = test_user - e = wdb.add_author( - user_id=user["id"], author_name="Remove Me", hardcover_author_id="20" - ) + e = wdb.add_author(user_id=user["id"], author_name="Remove Me", hardcover_author_id="20") result = wdb.remove_author(e["id"]) assert result is True assert wdb.get_author(e["id"]) is None @@ -307,9 +313,7 @@ def test_remove_author_returns_false_when_not_found(self, watchlist_db): def test_remove_author_is_idempotent(self, test_user): user, wdb = test_user - e = wdb.add_author( - user_id=user["id"], author_name="Remove Twice", hardcover_author_id="21" - ) + e = wdb.add_author(user_id=user["id"], author_name="Remove Twice", hardcover_author_id="21") wdb.remove_author(e["id"]) result = wdb.remove_author(e["id"]) assert result is False @@ -319,6 +323,7 @@ def test_remove_author_is_idempotent(self, test_user): # upsert_release # ------------------------------------------------------------------ + class TestUpsertRelease: def _sample_book_data(self) -> dict: return { @@ -372,9 +377,7 @@ def test_upsert_is_idempotent(self, test_user): def test_upsert_rejects_invalid_content_type(self, test_user): user, wdb = test_user - entry = wdb.add_author( - user_id=user["id"], author_name="Author", hardcover_author_id="100" - ) + entry = wdb.add_author(user_id=user["id"], author_name="Author", hardcover_author_id="100") with pytest.raises(ValueError, match="content_type"): wdb.upsert_release( watch_id=entry["id"], @@ -387,9 +390,7 @@ def test_upsert_rejects_invalid_content_type(self, test_user): def test_upsert_rejects_empty_book_data(self, test_user): user, wdb = test_user - entry = wdb.add_author( - user_id=user["id"], author_name="Author", hardcover_author_id="101" - ) + entry = wdb.add_author(user_id=user["id"], author_name="Author", hardcover_author_id="101") with pytest.raises(ValueError, match="book_data"): wdb.upsert_release( watch_id=entry["id"], @@ -405,6 +406,7 @@ def test_upsert_rejects_empty_book_data(self, test_user): # list_releases / update_release_action # ------------------------------------------------------------------ + class TestReleasesQueryAndUpdate: def _add_release(self, wdb, watch_id, user_id, book_id, content_type="ebook"): return wdb.upsert_release( @@ -418,9 +420,7 @@ def _add_release(self, wdb, watch_id, user_id, book_id, content_type="ebook"): def test_list_releases_returns_user_releases(self, test_user): user, wdb = test_user - entry = wdb.add_author( - user_id=user["id"], author_name="Author", hardcover_author_id="200" - ) + entry = wdb.add_author(user_id=user["id"], author_name="Author", hardcover_author_id="200") self._add_release(wdb, entry["id"], user["id"], "book-a") self._add_release(wdb, entry["id"], user["id"], "book-b") releases = wdb.list_releases(user["id"]) @@ -428,9 +428,7 @@ def test_list_releases_returns_user_releases(self, test_user): def test_list_releases_filter_by_status(self, test_user): user, wdb = test_user - entry = wdb.add_author( - user_id=user["id"], author_name="Author", hardcover_author_id="201" - ) + entry = wdb.add_author(user_id=user["id"], author_name="Author", hardcover_author_id="201") r = self._add_release(wdb, entry["id"], user["id"], "book-c") wdb.update_release_action(r["id"], action_status="queued") detected = wdb.list_releases(user["id"], action_status="detected") @@ -440,9 +438,7 @@ def test_list_releases_filter_by_status(self, test_user): def test_update_release_action_sets_status(self, test_user): user, wdb = test_user - entry = wdb.add_author( - user_id=user["id"], author_name="Author", hardcover_author_id="202" - ) + entry = wdb.add_author(user_id=user["id"], author_name="Author", hardcover_author_id="202") r = self._add_release(wdb, entry["id"], user["id"], "book-d") updated = wdb.update_release_action(r["id"], action_status="skipped") assert updated is not None @@ -451,9 +447,7 @@ def test_update_release_action_sets_status(self, test_user): def test_update_release_action_rejects_invalid_status(self, test_user): user, wdb = test_user - entry = wdb.add_author( - user_id=user["id"], author_name="Author", hardcover_author_id="203" - ) + entry = wdb.add_author(user_id=user["id"], author_name="Author", hardcover_author_id="203") r = self._add_release(wdb, entry["id"], user["id"], "book-e") with pytest.raises(ValueError, match="action_status"): wdb.update_release_action(r["id"], action_status="purchased") @@ -463,13 +457,12 @@ def test_update_release_action_rejects_invalid_status(self, test_user): # Cascade on user delete # ------------------------------------------------------------------ + class TestCascade: def test_delete_user_cascades_to_authors(self, full_db, db_path): udb, wdb = full_db user = udb.create_user(username="cascade_user", password_hash="x") - wdb.add_author( - user_id=user["id"], author_name="Cascade Author", hardcover_author_id="999" - ) + wdb.add_author(user_id=user["id"], author_name="Cascade Author", hardcover_author_id="999") udb.delete_user(user["id"]) conn = sqlite3.connect(db_path) conn.execute("PRAGMA foreign_keys = ON") From dd009b2441ccda91a77adb4e03cf3a79e5180cb2 Mon Sep 17 00:00:00 2001 From: spin-drift Date: Sun, 14 Jun 2026 00:58:18 -0400 Subject: [PATCH 3/6] docs: add meta-fork notice and tracked-forks list to README Calls out that this fork integrates noteworthy work from community forks while keeping Shelfmark's identity, license, and naming intact. Lists the forks currently tracked (litfinder, pulsarr) with a brief summary of what's been ported from each. --- readme.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/readme.md b/readme.md index 9aee1791..21ff7953 100644 --- a/readme.md +++ b/readme.md @@ -5,6 +5,14 @@ > [!NOTE] > This project is in a stable state as of May 2026 but is not under active maintenance. +> [!IMPORTANT] +> **This fork is a meta-fork of [calibrain/shelfmark](https://github.com/calibrain/shelfmark)** that selectively ports noteworthy work from community forks while keeping the Shelfmark identity, license, and naming intact. Each port is reviewed per-commit; rebranding, license switches, and major infrastructure changes are left out. Currently tracking: +> +> - [NemesisHubris/litfinder](https://github.com/NemesisHubris/litfinder) — bug fixes (#999, #956, #1010, #1021, #1025, #1031, #1040), multi-variant title search, multi-book flat-folder grouping, language detection from AA paths, "Leave in Place" output handler, and several quality-of-life improvements +> - [SloppyTurtle/pulsarr](https://github.com/SloppyTurtle/pulsarr) — author watchlist database, REST API, and test suite (issue #1) +> +> See `git log --grep="Cherry-picked from"` for the full list of attributed commits. Thanks to the maintainers of each fork for the work being ported. + Shelfmark is a self-hosted web interface for searching and requesting books and audiobooks across multiple sources. Bring your own sources, metadata providers, and download clients to build a single hub for your digital library. Supports multiple users with a built-in request system, so you can share your instance with others and let them browse and request books on their own. Works great alongside the following library tools, with support for automatic imports: From 5d454c072f7eccc50c511d6300c545766ed2a910 Mon Sep 17 00:00:00 2001 From: spindrift Date: Sun, 14 Jun 2026 01:09:48 -0400 Subject: [PATCH 4/6] Update meta-fork description in README --- readme.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/readme.md b/readme.md index 21ff7953..996f865f 100644 --- a/readme.md +++ b/readme.md @@ -6,7 +6,7 @@ > This project is in a stable state as of May 2026 but is not under active maintenance. > [!IMPORTANT] -> **This fork is a meta-fork of [calibrain/shelfmark](https://github.com/calibrain/shelfmark)** that selectively ports noteworthy work from community forks while keeping the Shelfmark identity, license, and naming intact. Each port is reviewed per-commit; rebranding, license switches, and major infrastructure changes are left out. Currently tracking: +> **This fork is a meta-fork of [calibrain/shelfmark](https://github.com/calibrain/shelfmark)** (which transitioned in May 2026 to maintenance updates only) that selectively ports noteworthy work from community forks while keeping the Shelfmark identity, license, and naming intact. Each port is reviewed per-commit; rebranding, license switches, and major infrastructure changes are left out. Currently tracking: > > - [NemesisHubris/litfinder](https://github.com/NemesisHubris/litfinder) — bug fixes (#999, #956, #1010, #1021, #1025, #1031, #1040), multi-variant title search, multi-book flat-folder grouping, language detection from AA paths, "Leave in Place" output handler, and several quality-of-life improvements > - [SloppyTurtle/pulsarr](https://github.com/SloppyTurtle/pulsarr) — author watchlist database, REST API, and test suite (issue #1) From 27f50fed544b09840966ab6385cd99092eb94eac Mon Sep 17 00:00:00 2001 From: spindrift Date: Sun, 14 Jun 2026 01:12:28 -0400 Subject: [PATCH 5/6] Clarify meta-fork description in README Updated the note about the fork to include new original features being added. --- readme.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/readme.md b/readme.md index 996f865f..e6982d12 100644 --- a/readme.md +++ b/readme.md @@ -6,7 +6,7 @@ > This project is in a stable state as of May 2026 but is not under active maintenance. > [!IMPORTANT] -> **This fork is a meta-fork of [calibrain/shelfmark](https://github.com/calibrain/shelfmark)** (which transitioned in May 2026 to maintenance updates only) that selectively ports noteworthy work from community forks while keeping the Shelfmark identity, license, and naming intact. Each port is reviewed per-commit; rebranding, license switches, and major infrastructure changes are left out. Currently tracking: +> **This fork is a meta-fork of [calibrain/shelfmark](https://github.com/calibrain/shelfmark)** (which transitioned in May 2026 to maintenance updates only) that selectively ports noteworthy work from community forks (and also adds new original features) while keeping the Shelfmark identity, license, and naming intact. Each port is reviewed per-commit; rebranding, license switches, and major infrastructure changes are left out. Currently tracking: > > - [NemesisHubris/litfinder](https://github.com/NemesisHubris/litfinder) — bug fixes (#999, #956, #1010, #1021, #1025, #1031, #1040), multi-variant title search, multi-book flat-folder grouping, language detection from AA paths, "Leave in Place" output handler, and several quality-of-life improvements > - [SloppyTurtle/pulsarr](https://github.com/SloppyTurtle/pulsarr) — author watchlist database, REST API, and test suite (issue #1) From b09f723f49aa2a61a126650996c33e4647ef69b0 Mon Sep 17 00:00:00 2001 From: spin-drift Date: Sun, 14 Jun 2026 01:19:10 -0400 Subject: [PATCH 6/6] fix(watchlist): don't leak sqlite error details in IntegrityError path The raw sqlite3.IntegrityError message can reveal internal index and constraint names (e.g. 'uq_watchlist_authors_hardcover') to the API caller, which is unnecessary info disclosure. Log the underlying error server-side and return a generic "Watch entry already exists" message to the caller instead. Addresses the one CodeQL py/stack-trace-exposure finding from the initial scan of this PR. Tests still pass (they match on "already exists", which the new message preserves). --- shelfmark/watchlist/db.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/shelfmark/watchlist/db.py b/shelfmark/watchlist/db.py index a7923f28..c10bb8ba 100644 --- a/shelfmark/watchlist/db.py +++ b/shelfmark/watchlist/db.py @@ -201,7 +201,10 @@ def add_author( msg = "Failed to load newly created watch entry" raise RuntimeError(msg) except sqlite3.IntegrityError as e: - msg = f"Watch entry already exists: {e}" + # Don't surface the raw sqlite error (which can reveal index/constraint + # names) to the API caller; log it server-side instead. + logger.warning("watchlist_authors integrity error: %s", e) + msg = "Watch entry already exists" raise ValueError(msg) from e else: return result