From efc9f2053fb7ee0c59e3f6a30bed87cf749fdd14 Mon Sep 17 00:00:00 2001 From: Jared Swets Date: Fri, 17 Apr 2026 01:35:23 +0000 Subject: [PATCH] feat: adding support for output to litara --- docs/configuration.md | 2 +- docs/custom-scripts.md | 9 +- docs/environment-variables.md | 35 +- docs/users-and-requests.md | 2 +- shelfmark/config/litara_settings.py | 55 +++ shelfmark/config/settings.py | 42 ++ shelfmark/download/outputs/__init__.py | 1 + shelfmark/download/outputs/litara.py | 379 ++++++++++++++++++ .../settings/users/UserOverridesSection.tsx | 5 +- 9 files changed, 521 insertions(+), 9 deletions(-) create mode 100644 shelfmark/config/litara_settings.py create mode 100644 shelfmark/download/outputs/litara.py diff --git a/docs/configuration.md b/docs/configuration.md index 5b1632f8..f83f5cda 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -42,7 +42,7 @@ services: Notes: - Point `/books` to your library ingest folder (Calibre-Web, Booklore, Audiobookshelf, etc) for automatic import. -- If you set Books Output Mode to Booklore (API), books are uploaded via API instead of written to `/books`. Audiobooks still use a destination folder. +- If you set Books Output Mode to Booklore (API) or Litara (API), books are uploaded via API instead of written to `/books`. Audiobooks still use a destination folder. - Ensure `PUID`/`PGID` (or legacy `UID`/`GID`) match the owner of the host directories. - For non-root mode, start the container as `1000:1000`. - On Kubernetes, set `runAsUser: 1000`, `runAsGroup: 1000`, and `runAsNonRoot: true` together. diff --git a/docs/custom-scripts.md b/docs/custom-scripts.md index de78ad5f..a0fc0c07 100644 --- a/docs/custom-scripts.md +++ b/docs/custom-scripts.md @@ -1,6 +1,6 @@ # Custom Scripts -Shelfmark can run an executable you provide after a download task completes successfully. The script runs after the selected output has finished (for example: transfer to the folder destination, or upload to Booklore). +Shelfmark can run an executable you provide after a download task completes successfully. The script runs after the selected output has finished (for example: transfer to the folder destination, or upload to Booklore/Litara). ## Quick Start (Recommended) @@ -72,6 +72,7 @@ What the target path refers to depends on the output mode: - Folder output (`output.mode=folder`, `phase=post_transfer`): the final imported file or folder inside your destination. - Booklore output (`output.mode=booklore`, `phase=post_upload`): the local file or folder that was uploaded (the destination is remote). +- Litara output (`output.mode=litara`, `phase=post_upload`): the local file or folder that was uploaded to Litara Book Drop (the destination is remote). By default, `$1` is an absolute path inside the Shelfmark container (or on your host, if you are not using Docker). @@ -83,8 +84,8 @@ When enabled, Shelfmark sends a versioned JSON payload to your script via stdin - The JSON payload always includes absolute paths in `paths.*`, even if you set Custom Script Path Mode to `relative` for `$1`. - `output.mode` tells you which output ran. -- `output.details` is output-specific. For Booklore output, `output.details.booklore` includes connection details such as `base_url`, `library_id`, and `path_id`. -- `phase` indicates when the script is running. Current values: `post_transfer` (folder output), `post_upload` (Booklore output). +- `output.details` is output-specific. For Booklore output, `output.details.booklore` includes connection details such as `base_url`, `library_id`, and `path_id`. For Litara output, `output.details.litara` includes `base_url`. +- `phase` indicates when the script is running. Current values: `post_transfer` (folder output), `post_upload` (Booklore and Litara output). - `transfer` is only included for outputs that do a local transfer (for example the folder output). If JSON payload is disabled, stdin is empty (EOF). Don't `cat` stdin unless you've enabled the payload. @@ -192,4 +193,4 @@ Note: if the target is the destination folder itself, `relative` mode may pass ` ## Notes And Caveats - **Hardlinks and torrents:** if you use hardlinking to keep seeding, avoid scripts that modify file contents, since hardlinked files share data with the seeding copy. -- **Booklore output mode:** scripts run after upload. `$1` will point at the local uploaded file (or staging folder). +- **Booklore/Litara output mode:** scripts run after upload. `$1` will point at the local uploaded file (or staging folder). diff --git a/docs/environment-variables.md b/docs/environment-variables.md index 71521efa..ea588085 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -303,6 +303,9 @@ The release source tab to open by default in the release modal for audiobooks. U | `BOOKLORE_DESTINATION` | Choose whether uploads go directly to a specific library path or to Bookdrop for review. | string (choice) | `library` | | `BOOKLORE_LIBRARY_ID` | Grimmory library to upload into. | string (choice) | _none_ | | `BOOKLORE_PATH_ID` | Grimmory library path for uploads. | string (choice) | _none_ | +| `LITARA_HOST` | Base URL of your Litara instance | string | _none_ | +| `LITARA_EMAIL` | Litara account email address | string | _none_ | +| `LITARA_PASSWORD` | Litara account password | string (secret) | _none_ | | `EMAIL_RECIPIENT` | Optional fallback email address when no per-user email recipient override is configured. | string | _none_ | | `EMAIL_ATTACHMENT_SIZE_LIMIT_MB` | Maximum total attachment size per email. Email encoding adds overhead; keep this below your provider's limit. | number | `25` | | `EMAIL_SMTP_HOST` | SMTP server hostname or IP (e.g., smtp.gmail.com). | string | _none_ | @@ -335,7 +338,7 @@ Choose where completed book files are sent. - **Type:** string (choice) - **Default:** `folder` -- **Options:** `folder` (Folder), `email` (Email (SMTP)), `booklore` (Grimmory (API)) +- **Options:** `folder` (Folder), `email` (Email (SMTP)), `booklore` (Grimmory (API)), `litara` (Litara (API)) #### `INGEST_DIR` @@ -444,6 +447,36 @@ Grimmory library path for uploads. - **Default:** _none_ - **Required:** Yes +#### `LITARA_HOST` + +**Litara URL** + +Base URL of your Litara instance + +- **Type:** string +- **Default:** _none_ +- **Required:** Yes + +#### `LITARA_EMAIL` + +**Email** + +Litara account email address + +- **Type:** string +- **Default:** _none_ +- **Required:** Yes + +#### `LITARA_PASSWORD` + +**Password** + +Litara account password + +- **Type:** string (secret) +- **Default:** _none_ +- **Required:** Yes + #### `EMAIL_RECIPIENT` **Default Email Recipient** diff --git a/docs/users-and-requests.md b/docs/users-and-requests.md index 79c81e40..a3498673 100644 --- a/docs/users-and-requests.md +++ b/docs/users-and-requests.md @@ -36,7 +36,7 @@ There are three categories of per-user settings: Override where a user's downloads are sent. Options depend on the global output mode configuration: -- **Output mode** — Folder, Email (SMTP), or BookLore (API) +- **Output mode** — Folder, Email (SMTP), BookLore (API), or Litara (API) - **Destination** — A custom folder path for this user's ebook downloads - **Audiobook destination** — A custom folder path for audiobook downloads - **BookLore library/path** — Per-user BookLore target (when using BookLore output mode) diff --git a/shelfmark/config/litara_settings.py b/shelfmark/config/litara_settings.py new file mode 100644 index 00000000..efc758dd --- /dev/null +++ b/shelfmark/config/litara_settings.py @@ -0,0 +1,55 @@ +"""Helpers for Litara settings validation and connection tests.""" + +from __future__ import annotations + +from typing import Any + +from shelfmark.core.config import config +from shelfmark.core.logger import setup_logger +from shelfmark.download.outputs.litara import ( + LitaraConfig, + LitaraError, + litara_login, +) + +logger = setup_logger(__name__) + + +def check_litara_connection( + current_values: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Test the Litara connection using current form values.""" + current_values = current_values or {} + + def _get_value(key: str, default: object = None) -> object: + value = current_values.get(key) + if value not in (None, ""): + return value + if default is None: + return config.get(key) + return config.get(key, default) + + base_url = str(_get_value("LITARA_HOST", "") or "").strip().rstrip("/") + email = str(_get_value("LITARA_EMAIL", "") or "").strip() + password = str(_get_value("LITARA_PASSWORD", "") or "") + + if not base_url: + return {"success": False, "message": "Litara URL is required"} + if not email: + return {"success": False, "message": "Litara email is required"} + if not password: + return {"success": False, "message": "Litara password is required"} + + litara_config = LitaraConfig( + base_url=base_url, + email=email, + password=password, + verify_tls=True, + ) + + try: + litara_login(litara_config) + except LitaraError as exc: + return {"success": False, "message": str(exc)} + else: + return {"success": True, "message": "Connected to Litara"} diff --git a/shelfmark/config/settings.py b/shelfmark/config/settings.py index 5a2049b0..bd23e32e 100644 --- a/shelfmark/config/settings.py +++ b/shelfmark/config/settings.py @@ -15,6 +15,7 @@ check_books_destination, ) from shelfmark.config.email_settings import check_email_connection +from shelfmark.config.litara_settings import check_litara_connection from shelfmark.core.logger import setup_logger from shelfmark.core.settings_registry import ( ActionButton, @@ -875,6 +876,11 @@ def download_settings() -> list[SettingsField]: "label": "Grimmory (API)", "description": "Upload files directly to Grimmory", }, + { + "value": "litara", + "label": "Litara (API)", + "description": "Upload files directly to Litara", + }, ], default="folder", user_overridable=True, @@ -1045,6 +1051,42 @@ def download_settings() -> list[SettingsField]: callback=check_booklore_connection, show_when={"field": "BOOKS_OUTPUT_MODE", "value": "booklore"}, ), + HeadingField( + key="litara_heading", + title="Litara", + description="Upload books directly to Litara Book Drop via API. Audiobooks are not supported and will use folder mode.", + show_when={"field": "BOOKS_OUTPUT_MODE", "value": "litara"}, + ), + TextField( + key="LITARA_HOST", + label="Litara URL", + description="Base URL of your Litara instance", + placeholder="http://litara:3000", + required=True, + show_when={"field": "BOOKS_OUTPUT_MODE", "value": "litara"}, + ), + TextField( + key="LITARA_EMAIL", + label="Email", + description="Litara account email address", + required=True, + show_when={"field": "BOOKS_OUTPUT_MODE", "value": "litara"}, + ), + PasswordField( + key="LITARA_PASSWORD", + label="Password", + description="Litara account password", + required=True, + show_when={"field": "BOOKS_OUTPUT_MODE", "value": "litara"}, + ), + ActionButton( + key="test_litara", + label="Test Connection", + description="Verify your Litara configuration", + style="primary", + callback=check_litara_connection, + show_when={"field": "BOOKS_OUTPUT_MODE", "value": "litara"}, + ), HeadingField( key="email_heading", title="Email", diff --git a/shelfmark/download/outputs/__init__.py b/shelfmark/download/outputs/__init__.py index 65a2b883..20904b28 100644 --- a/shelfmark/download/outputs/__init__.py +++ b/shelfmark/download/outputs/__init__.py @@ -74,6 +74,7 @@ def load_output_handlers() -> None: from . import booklore as booklore from . import email as email from . import folder as folder + from . import litara as litara _OUTPUTS_LOADED = True diff --git a/shelfmark/download/outputs/litara.py b/shelfmark/download/outputs/litara.py new file mode 100644 index 00000000..192c726b --- /dev/null +++ b/shelfmark/download/outputs/litara.py @@ -0,0 +1,379 @@ +"""Litara output integration for uploading completed downloads.""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING, Any + +import requests + +import shelfmark.core.config as core_config +from shelfmark.core.logger import setup_logger +from shelfmark.core.utils import is_audiobook as check_audiobook +from shelfmark.download.outputs import StatusCallback, register_output +from shelfmark.download.staging import ( + STAGE_COPY, + STAGE_MOVE, + STAGE_NONE, + build_staging_dir, + get_staging_dir, +) + +if TYPE_CHECKING: + from threading import Event + + from shelfmark.core.models import DownloadTask + +logger = setup_logger(__name__) + +LITARA_OUTPUT_MODE = "litara" +LITARA_SUPPORTED_EXTENSIONS = { + ".azw", + ".azw3", + ".cb7", + ".cbr", + ".cbz", + ".epub", + ".fb2", + ".mobi", + ".pdf", +} +LITARA_SUPPORTED_FORMATS_LABEL = ", ".join( + ext.lstrip(".").upper() for ext in sorted(LITARA_SUPPORTED_EXTENSIONS) +) +LITARA_DISPLAY_NAME = "Litara" + + +class LitaraError(Exception): + """Raised when Litara integration fails.""" + + +@dataclass(frozen=True) +class LitaraConfig: + """Configuration required to upload files into Litara.""" + + base_url: str + email: str + password: str + verify_tls: bool = True + + +def build_litara_config(values: dict[str, Any]) -> LitaraConfig: + """Build and validate the effective Litara configuration.""" + base_url = str(values.get("LITARA_HOST", "")).strip() + email = str(values.get("LITARA_EMAIL", "")).strip() + password = values.get("LITARA_PASSWORD", "") or "" + + if not base_url: + msg = f"{LITARA_DISPLAY_NAME} URL is required" + raise LitaraError(msg) + if not email: + msg = f"{LITARA_DISPLAY_NAME} email is required" + raise LitaraError(msg) + if not password: + msg = f"{LITARA_DISPLAY_NAME} password is required" + raise LitaraError(msg) + + return LitaraConfig( + base_url=base_url.rstrip("/"), + email=email, + password=password, + verify_tls=True, + ) + + +def litara_login(litara_config: LitaraConfig) -> str: + """Authenticate with Litara and return an API token.""" + url = f"{litara_config.base_url}/api/v1/auth/login" + payload = { + "email": litara_config.email, + "password": litara_config.password, + } + + try: + response = requests.post(url, json=payload, timeout=30, verify=litara_config.verify_tls) + except requests.exceptions.ConnectionError as exc: + msg = f"Could not connect to {LITARA_DISPLAY_NAME}" + raise LitaraError(msg) from exc + except requests.exceptions.Timeout as exc: + msg = f"{LITARA_DISPLAY_NAME} connection timed out" + raise LitaraError(msg) from exc + except requests.exceptions.RequestException as exc: + msg = f"{LITARA_DISPLAY_NAME} login failed: {exc}" + raise LitaraError(msg) from exc + + if response.status_code in {401, 403}: + msg = f"{LITARA_DISPLAY_NAME} authentication failed" + raise LitaraError(msg) + + try: + response.raise_for_status() + except requests.exceptions.HTTPError as exc: + msg = f"{LITARA_DISPLAY_NAME} login failed ({response.status_code})" + raise LitaraError(msg) from exc + + try: + data = response.json() + except ValueError as exc: + msg = f"Invalid {LITARA_DISPLAY_NAME} login response" + raise LitaraError(msg) from exc + + token = data.get("access_token") + if not token: + msg = f"{LITARA_DISPLAY_NAME} did not return an access token" + raise LitaraError(msg) + + return token + + +def litara_upload_file(litara_config: LitaraConfig, token: str, file_path: Path) -> None: + """Upload a completed file into Litara's book drop.""" + url = f"{litara_config.base_url}/api/v1/book-drop/upload" + headers = {"Authorization": f"Bearer {token}"} + + response = None + + try: + with file_path.open("rb") as handle: + response = requests.post( + url, + headers=headers, + files={"files": (file_path.name, handle)}, + timeout=60, + verify=litara_config.verify_tls, + ) + response.raise_for_status() + except requests.exceptions.HTTPError as exc: + message = response.text.strip() if response is not None else "" + if message: + message = f": {message[:200]}" + status_code = response.status_code if response is not None else "unknown" + msg = f"{LITARA_DISPLAY_NAME} upload failed ({status_code}){message}" + raise LitaraError(msg) from exc + except requests.exceptions.ConnectionError as exc: + msg = f"Could not connect to {LITARA_DISPLAY_NAME}" + raise LitaraError(msg) from exc + except requests.exceptions.Timeout as exc: + msg = f"{LITARA_DISPLAY_NAME} upload timed out" + raise LitaraError(msg) from exc + except requests.exceptions.RequestException as exc: + msg = f"{LITARA_DISPLAY_NAME} upload failed: {exc}" + raise LitaraError(msg) from exc + + +def litara_scan_library(litara_config: LitaraConfig, token: str) -> None: + """Trigger a Litara library scan after upload.""" + url = f"{litara_config.base_url}/api/v1/library/scan" + headers = {"Authorization": f"Bearer {token}"} + + try: + response = requests.post( + url, + headers=headers, + params={"rescanMetadata": "false"}, + timeout=30, + verify=litara_config.verify_tls, + ) + response.raise_for_status() + except requests.exceptions.RequestException as exc: + msg = f"{LITARA_DISPLAY_NAME} library scan failed: {exc}" + raise LitaraError(msg) from exc + + +def _supports_litara(task: DownloadTask) -> bool: + return not check_audiobook(task.content_type) + + +def _get_litara_settings() -> dict[str, Any]: + return { + "LITARA_HOST": core_config.config.get("LITARA_HOST", ""), + "LITARA_EMAIL": core_config.config.get("LITARA_EMAIL", ""), + "LITARA_PASSWORD": core_config.config.get("LITARA_PASSWORD", ""), + } + + +def _litara_format_error(rejected_files: list[Path]) -> str: + rejected_exts = sorted({f.suffix.lower() for f in rejected_files}) + rejected_list = ", ".join(rejected_exts) + return ( + f"{LITARA_DISPLAY_NAME} does not support {rejected_list}. " + f"Supported formats: {LITARA_SUPPORTED_FORMATS_LABEL}" + ) + + +def _post_process_litara( + temp_file: Path, + task: DownloadTask, + cancel_flag: Event, + status_callback: StatusCallback, + *, + preserve_source_on_failure: bool = False, +) -> str | None: + from shelfmark.download.postprocess.pipeline import ( + CustomScriptContext, + OutputPlan, + cleanup_output_staging, + is_managed_workspace_path, + maybe_run_custom_script, + prepare_output_files, + safe_cleanup_path, + ) + + if cancel_flag.is_set(): + logger.info("Task %s: cancelled before Litara upload", task.task_id) + return None + + try: + litara_config = build_litara_config(_get_litara_settings()) + except LitaraError as e: + logger.warning("Task %s: Litara configuration error: %s", task.task_id, e) + status_callback("error", str(e)) + return None + + status_callback("resolving", f"Preparing {LITARA_DISPLAY_NAME} upload") + + stage_action = STAGE_NONE + if is_managed_workspace_path(temp_file): + stage_action = STAGE_COPY if preserve_source_on_failure else STAGE_MOVE + staging_dir = ( + build_staging_dir("litara", task.task_id) + if stage_action != STAGE_NONE + else get_staging_dir() + ) + + output_plan = OutputPlan( + mode=LITARA_OUTPUT_MODE, + stage_action=stage_action, + staging_dir=staging_dir, + allow_archive_extraction=True, + ) + + prepared = prepare_output_files( + temp_file, + task, + LITARA_OUTPUT_MODE, + status_callback, + output_plan=output_plan, + preserve_source_on_failure=preserve_source_on_failure, + ) + if not prepared: + return None + + logger.debug( + "Task %s: prepared %d file(s) for Litara upload", + task.task_id, + len(prepared.files), + ) + + success = False + try: + unsupported_files = [ + file_path + for file_path in prepared.files + if file_path.suffix.lower() not in LITARA_SUPPORTED_EXTENSIONS + ] + if unsupported_files: + error_message = _litara_format_error(unsupported_files) + logger.warning("Task %s: %s", task.task_id, error_message) + status_callback("error", error_message) + return None + + token = litara_login(litara_config) + logger.info( + "Task %s: uploading %d file(s) to Litara", + task.task_id, + len(prepared.files), + ) + + for index, file_path in enumerate(prepared.files, start=1): + if cancel_flag.is_set(): + logger.info("Task %s: cancelled during Litara upload", task.task_id) + return None + status_callback( + "resolving", + f"Uploading to {LITARA_DISPLAY_NAME} ({index}/{len(prepared.files)})", + ) + litara_upload_file(litara_config, token, file_path) + + try: + litara_scan_library(litara_config, token) + except LitaraError as e: + logger.warning("Task %s: Litara library scan failed: %s", task.task_id, e) + + logger.info( + "Task %s: uploaded %d file(s) to Litara", + task.task_id, + len(prepared.files), + ) + + destination: Path | None + if len(prepared.files) == 1: + destination = prepared.files[0].parent + else: + try: + destination = Path(os.path.commonpath([str(p.parent) for p in prepared.files])) + except ValueError: + destination = prepared.files[0].parent if prepared.files else None + + script_context = CustomScriptContext( + task=task, + phase="post_upload", + output_mode=LITARA_OUTPUT_MODE, + destination=destination, + final_paths=prepared.files, + output_details={ + "litara": { + "base_url": litara_config.base_url, + } + }, + ) + if not maybe_run_custom_script(script_context, status_callback=status_callback): + return None + + message = f"Uploaded to {LITARA_DISPLAY_NAME}" + if len(prepared.files) > 1: + message = f"Uploaded to {LITARA_DISPLAY_NAME} ({len(prepared.files)} files)" + status_callback("complete", message) + success = True + output_path = f"litara://{task.task_id}" + + except LitaraError as e: + logger.warning("Task %s: Litara upload failed: %s", task.task_id, e) + status_callback("error", str(e)) + return None + except (OSError, TypeError, ValueError) as e: + logger.error_trace("Task %s: unexpected error uploading to Litara: %s", task.task_id, e) + status_callback("error", f"{LITARA_DISPLAY_NAME} upload failed: {e}") + return None + else: + return output_path + finally: + cleanup_output_staging( + prepared.output_plan, + prepared.working_path, + task, + prepared.cleanup_paths, + ) + if preserve_source_on_failure and success: + safe_cleanup_path(temp_file, task) + + +@register_output(LITARA_OUTPUT_MODE, supports_task=_supports_litara, priority=10) +def process_litara_output( + temp_file: Path, + task: DownloadTask, + cancel_flag: Event, + status_callback: StatusCallback, + *, + preserve_source_on_failure: bool = False, +) -> str | None: + """Process a completed download through the Litara output.""" + return _post_process_litara( + temp_file, + task, + cancel_flag, + status_callback, + preserve_source_on_failure=preserve_source_on_failure, + ) diff --git a/src/frontend/src/components/settings/users/UserOverridesSection.tsx b/src/frontend/src/components/settings/users/UserOverridesSection.tsx index 6059ef64..64723c59 100644 --- a/src/frontend/src/components/settings/users/UserOverridesSection.tsx +++ b/src/frontend/src/components/settings/users/UserOverridesSection.tsx @@ -21,6 +21,7 @@ const modeOptions = [ { value: 'folder', label: 'Folder' }, { value: 'email', label: 'Email (SMTP)' }, { value: 'booklore', label: 'Grimmory (API)' }, + { value: 'litara', label: 'Litara (API)' }, ]; const fallbackOutputModeField: SelectFieldConfig = { @@ -93,9 +94,9 @@ const fallbackBrowserDownloadField: MultiSelectFieldConfig = { type DeliverySettingKey = keyof PerUserSettings; -function normalizeMode(value: unknown): 'folder' | 'booklore' | 'email' { +function normalizeMode(value: unknown): 'folder' | 'booklore' | 'email' | 'litara' { const mode = toNormalizedLowercaseTextValue(value); - if (mode === 'booklore' || mode === 'email') { + if (mode === 'booklore' || mode === 'email' || mode === 'litara') { return mode; } return 'folder';