mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 13:40:27 +02:00
feat: Add subtitle support in OPDSEntry and enhance metadata handling in API routes
This commit is contained in:
@@ -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,
|
||||
|
||||
+80
-58
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user