diff --git a/abogen/integrations/calibre_opds.py b/abogen/integrations/calibre_opds.py index ab24bb1..eec4f86 100644 --- a/abogen/integrations/calibre_opds.py +++ b/abogen/integrations/calibre_opds.py @@ -77,6 +77,7 @@ class OPDSEntry: title: str position: Optional[int] = None authors: List[str] = field(default_factory=list) + subtitle: Optional[str] = None updated: Optional[str] = None published: Optional[str] = None summary: Optional[str] = None @@ -96,6 +97,7 @@ class OPDSEntry: "title": self.title, "position": self.position, "authors": list(self.authors), + "subtitle": self.subtitle, "updated": self.updated, "published": self.published, "summary": self.summary, @@ -599,6 +601,14 @@ class CalibreOPDSClient: def _parse_entry(self, node: ET.Element, base_url: str) -> OPDSEntry: entry_id = node.findtext("atom:id", default="", namespaces=NS).strip() title = node.findtext("atom:title", default="Untitled", namespaces=NS).strip() or "Untitled" + + subtitle = ( + node.findtext("calibre_md:subtitle", default=None, namespaces=NS) + or node.findtext("calibre:subtitle", default=None, namespaces=NS) + or node.findtext("atom:subtitle", default=None, namespaces=NS) + ) + subtitle = self._strip_html(subtitle.strip()) if subtitle else None + position_value = self._extract_position(node) updated = node.findtext("atom:updated", default=None, namespaces=NS) published = ( @@ -691,6 +701,7 @@ class CalibreOPDSClient: title=title, position=position_value, authors=authors, + subtitle=subtitle, updated=updated, published=published, summary=cleaned_summary, diff --git a/abogen/web/routes/api.py b/abogen/web/routes/api.py index 54a30fc..84973b0 100644 --- a/abogen/web/routes/api.py +++ b/abogen/web/routes/api.py @@ -260,6 +260,85 @@ def api_speaker_preview() -> ResponseReturnValue: # --- Integration Routes --- + +def _opds_metadata_overrides(metadata_payload: Mapping[str, Any]) -> Dict[str, Any]: + metadata_overrides: Dict[str, Any] = {} + + def _stringify_metadata_value(value: Any) -> str: + if value is None: + return "" + if isinstance(value, (list, tuple, set)): + parts = [str(item).strip() for item in value if item is not None] + parts = [part for part in parts if part] + return ", ".join(parts) + return str(value).strip() + + raw_series = metadata_payload.get("series") or metadata_payload.get("series_name") + series_name = str(raw_series or "").strip() + if series_name: + metadata_overrides["series"] = series_name + metadata_overrides.setdefault("series_name", series_name) + + series_index_value = ( + metadata_payload.get("series_index") + or metadata_payload.get("series_position") + or metadata_payload.get("series_sequence") + or metadata_payload.get("book_number") + ) + if series_index_value is not None: + series_index_text = str(series_index_value).strip() + if series_index_text: + metadata_overrides.setdefault("series_index", series_index_text) + metadata_overrides.setdefault("series_position", series_index_text) + metadata_overrides.setdefault("series_sequence", series_index_text) + metadata_overrides.setdefault("book_number", series_index_text) + + tags_value = metadata_payload.get("tags") or metadata_payload.get("keywords") + if tags_value: + tags_text = _stringify_metadata_value(tags_value) + if tags_text: + metadata_overrides.setdefault("tags", tags_text) + metadata_overrides.setdefault("keywords", tags_text) + metadata_overrides.setdefault("genre", tags_text) + + description_value = metadata_payload.get("description") or metadata_payload.get("summary") + if description_value: + description_text = _stringify_metadata_value(description_value) + if description_text: + metadata_overrides.setdefault("description", description_text) + metadata_overrides.setdefault("summary", description_text) + + subtitle_value = ( + metadata_payload.get("subtitle") + or metadata_payload.get("sub_title") + or metadata_payload.get("calibre_subtitle") + ) + if subtitle_value: + subtitle_text = _stringify_metadata_value(subtitle_value) + if subtitle_text: + metadata_overrides.setdefault("subtitle", subtitle_text) + + publisher_value = metadata_payload.get("publisher") + if publisher_value: + publisher_text = _stringify_metadata_value(publisher_value) + if publisher_text: + metadata_overrides.setdefault("publisher", publisher_text) + + # Author mapping: Abogen templates look for either 'authors' or 'author'. + authors_value = ( + metadata_payload.get("authors") + or metadata_payload.get("author") + or metadata_payload.get("creator") + or metadata_payload.get("dc_creator") + ) + if authors_value: + authors_text = _stringify_metadata_value(authors_value) + if authors_text: + metadata_overrides.setdefault("authors", authors_text) + metadata_overrides.setdefault("author", authors_text) + + return metadata_overrides + @api_bp.get("/integrations/calibre-opds/feed") def api_calibre_opds_feed() -> ResponseReturnValue: integrations = load_integration_settings() @@ -383,65 +462,8 @@ def api_calibre_opds_import() -> ResponseReturnValue: metadata_payload = data.get("metadata") if isinstance(data, Mapping) else None metadata_overrides: Dict[str, Any] = {} - if isinstance(metadata_payload, Mapping): - def _stringify_metadata_value(value: Any) -> str: - if value is None: - return "" - if isinstance(value, (list, tuple, set)): - parts = [str(item).strip() for item in value if item is not None] - parts = [part for part in parts if part] - return ", ".join(parts) - text = str(value).strip() - return text - - raw_series = metadata_payload.get("series") or metadata_payload.get("series_name") - series_name = str(raw_series or "").strip() - - if series_name: - metadata_overrides["series"] = series_name - metadata_overrides.setdefault("series_name", series_name) - - series_index_value = ( - metadata_payload.get("series_index") - or metadata_payload.get("series_position") - or metadata_payload.get("series_sequence") - or metadata_payload.get("book_number") - ) - if series_index_value is not None: - series_index_text = str(series_index_value).strip() - if series_index_text: - metadata_overrides.setdefault("series_index", series_index_text) - metadata_overrides.setdefault("series_position", series_index_text) - metadata_overrides.setdefault("series_sequence", series_index_text) - metadata_overrides.setdefault("book_number", series_index_text) - - tags_value = metadata_payload.get("tags") or metadata_payload.get("keywords") - if tags_value: - tags_text = _stringify_metadata_value(tags_value) - if tags_text: - metadata_overrides.setdefault("tags", tags_text) - metadata_overrides.setdefault("keywords", tags_text) - metadata_overrides.setdefault("genre", tags_text) - - description_value = metadata_payload.get("description") or metadata_payload.get("summary") - if description_value: - description_text = _stringify_metadata_value(description_value) - if description_text: - metadata_overrides.setdefault("description", description_text) - metadata_overrides.setdefault("summary", description_text) - - subtitle_value = metadata_payload.get("subtitle") - if subtitle_value: - subtitle_text = _stringify_metadata_value(subtitle_value) - if subtitle_text: - metadata_overrides.setdefault("subtitle", subtitle_text) - - publisher_value = metadata_payload.get("publisher") - if publisher_value: - publisher_text = _stringify_metadata_value(publisher_value) - if publisher_text: - metadata_overrides.setdefault("publisher", publisher_text) + metadata_overrides = _opds_metadata_overrides(metadata_payload) settings = load_settings() integrations = load_integration_settings() diff --git a/abogen/web/routes/settings.py b/abogen/web/routes/settings.py index a04faf1..c6c745c 100644 --- a/abogen/web/routes/settings.py +++ b/abogen/web/routes/settings.py @@ -1,5 +1,8 @@ from pathlib import Path +from collections.abc import Mapping +from typing import Any + from flask import Blueprint, current_app, render_template, request, redirect, url_for, flash, send_file, abort from flask.typing import ResponseReturnValue @@ -7,6 +10,7 @@ from abogen.web.routes.utils.settings import ( load_settings, load_integration_settings, save_settings, + stored_integration_config, coerce_bool, coerce_int, SAVE_MODE_LABELS, @@ -18,7 +22,7 @@ from abogen.web.routes.utils.settings import ( from abogen.web.routes.utils.voice import template_options from abogen.web.debug_tts_runner import run_debug_tts_wavs from abogen.debug_tts_samples import DEBUG_TTS_SAMPLES -from abogen.utils import get_user_output_path +from abogen.utils import get_user_output_path, load_config settings_bp = Blueprint("settings", __name__) @@ -105,7 +109,22 @@ def update_settings() -> ResponseReturnValue: current[key] = (form.get(key) or "").strip() # Integrations - current["integrations"] = current.get("integrations", {}) + # `load_settings()` returns only the general settings subset and intentionally + # does not include stored integrations. Seed them from the stored config so + # saving unrelated settings cannot wipe credentials/tokens. + current_integrations: dict[str, dict[str, Any]] = {} + cfg = load_config() or {} + stored_integrations = cfg.get("integrations") + if isinstance(stored_integrations, Mapping): + for name, payload in stored_integrations.items(): + if isinstance(name, str) and isinstance(payload, Mapping): + current_integrations[name] = dict(payload) + # Ensure known integrations are loaded even if the config is still in legacy format. + for name in ("audiobookshelf", "calibre_opds"): + stored = stored_integration_config(name) + if stored and name not in current_integrations: + current_integrations[name] = dict(stored) + current["integrations"] = current_integrations # Audiobookshelf abs_enabled = coerce_bool(form.get("audiobookshelf_enabled"), False) diff --git a/abogen/web/static/find_books.js b/abogen/web/static/find_books.js index e60f59c..a02abf6 100644 --- a/abogen/web/static/find_books.js +++ b/abogen/web/static/find_books.js @@ -759,6 +759,18 @@ if (modal && browser) { metadata.description = entry.summary; metadata.summary = entry.summary; } + + if (Array.isArray(entry.authors) && entry.authors.length > 0) { + const authorsText = entry.authors.map((name) => String(name || '').trim()).filter(Boolean).join(', '); + if (authorsText) { + metadata.authors = authorsText; + metadata.author = authorsText; + } + } + + if (typeof entry.subtitle === 'string' && entry.subtitle.trim()) { + metadata.subtitle = entry.subtitle.trim(); + } if (entry.rating !== null && entry.rating !== undefined && entry.rating !== '') { metadata.rating = String(entry.rating); } diff --git a/tests/test_calibre_opds.py b/tests/test_calibre_opds.py index 5227af5..f2e001e 100644 --- a/tests/test_calibre_opds.py +++ b/tests/test_calibre_opds.py @@ -33,6 +33,32 @@ def test_calibre_opds_feed_exposes_series_metadata() -> None: assert feed_dict["entries"][0]["series_index"] == 4.0 +def test_calibre_opds_feed_exposes_subtitle_metadata() -> None: + client = CalibreOPDSClient("http://example.com/catalog") + xml_payload = """ + + catalog + Example Catalog + + book-1 + Sample Book + A Novel + + + + """ + + feed = client._parse_feed(xml_payload, base_url="http://example.com/catalog") + assert feed.entries + assert feed.entries[0].subtitle == "A Novel" + + feed_dict = feed_to_dict(feed) + assert feed_dict["entries"][0]["subtitle"] == "A Novel" + + def test_calibre_opds_feed_extracts_series_from_categories() -> None: client = CalibreOPDSClient("http://example.com/catalog") xml_payload = """ diff --git a/tests/test_opds_import_metadata_mapping.py b/tests/test_opds_import_metadata_mapping.py new file mode 100644 index 0000000..63e20b5 --- /dev/null +++ b/tests/test_opds_import_metadata_mapping.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from abogen.web.routes.api import _opds_metadata_overrides + + +def test_opds_metadata_overrides_maps_author_and_subtitle() -> None: + overrides = _opds_metadata_overrides( + { + "authors": ["Alexandre Dumas"], + "subtitle": "Unabridged", + "series": "Example", + "series_index": 2, + "tags": ["Fiction", "Classic"], + "summary": "Summary text", + } + ) + + assert overrides["authors"] == "Alexandre Dumas" + assert overrides["author"] == "Alexandre Dumas" + assert overrides["subtitle"] == "Unabridged" + + # Existing behavior still present + assert overrides["series"] == "Example" + assert overrides["series_index"] == "2" + assert overrides["tags"] == "Fiction, Classic" + assert overrides["description"] == "Summary text" + + +def test_opds_metadata_overrides_accepts_author_string() -> None: + overrides = _opds_metadata_overrides({"author": "Mary Shelley"}) + assert overrides["authors"] == "Mary Shelley" + assert overrides["author"] == "Mary Shelley" diff --git a/tests/test_settings_integrations_secrets.py b/tests/test_settings_integrations_secrets.py new file mode 100644 index 0000000..0b77227 --- /dev/null +++ b/tests/test_settings_integrations_secrets.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +from pathlib import Path + +from abogen.utils import load_config, save_config +from abogen.web.app import create_app + + +def test_settings_update_preserves_abs_api_token_when_blank(tmp_path): + # Seed config with stored integration secret. + save_config( + { + "language": "en", + "integrations": { + "audiobookshelf": { + "enabled": True, + "base_url": "https://abs.example", + "api_token": "SECRET_TOKEN", + "library_id": "lib1", + "folder_id": "fold1", + "verify_ssl": True, + }, + "calibre_opds": { + "enabled": True, + "base_url": "https://opds.example", + "username": "user", + "password": "SECRET_PASS", + "verify_ssl": True, + }, + }, + } + ) + + app = create_app( + { + "TESTING": True, + "SECRET_KEY": "test", + "OUTPUT_FOLDER": str(tmp_path), + "UPLOAD_FOLDER": str(tmp_path / "uploads"), + } + ) + + with app.test_client() as client: + # Emulate saving settings where integrations are present but secrets are blank + # (typical of masked password/token inputs). + resp = client.post( + "/settings/update", + data={ + "language": "en", + "output_format": "mp3", + # ABS integration fields (token blank) + "audiobookshelf_enabled": "on", + "audiobookshelf_base_url": "https://abs.example", + "audiobookshelf_api_token": "", + "audiobookshelf_library_id": "lib1", + "audiobookshelf_folder_id": "fold1", + "audiobookshelf_verify_ssl": "on", + # Calibre OPDS integration fields (password blank) + "calibre_opds_enabled": "on", + "calibre_opds_base_url": "https://opds.example", + "calibre_opds_username": "user", + "calibre_opds_password": "", + "calibre_opds_verify_ssl": "on", + }, + follow_redirects=False, + ) + assert resp.status_code in {302, 303} + + cfg = load_config() or {} + integrations = cfg.get("integrations") or {} + + assert integrations["audiobookshelf"]["api_token"] == "SECRET_TOKEN" + assert integrations["calibre_opds"]["password"] == "SECRET_PASS" + + +def test_settings_update_preserves_secrets_when_fields_missing(tmp_path): + save_config( + { + "language": "en", + "integrations": { + "audiobookshelf": {"api_token": "SECRET_TOKEN"}, + "calibre_opds": {"password": "SECRET_PASS"}, + }, + } + ) + + app = create_app( + { + "TESTING": True, + "SECRET_KEY": "test", + "OUTPUT_FOLDER": str(tmp_path), + "UPLOAD_FOLDER": str(tmp_path / "uploads"), + } + ) + + with app.test_client() as client: + # Post unrelated changes; omit integration fields completely. + resp = client.post( + "/settings/update", + data={ + "language": "en", + "output_format": "wav", + }, + follow_redirects=False, + ) + assert resp.status_code in {302, 303} + + cfg = load_config() or {} + integrations = cfg.get("integrations") or {} + assert integrations["audiobookshelf"]["api_token"] == "SECRET_TOKEN" + assert integrations["calibre_opds"]["password"] == "SECRET_PASS"