mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 13:40:27 +02:00
feat: Add support for series metadata and closing outro in audio conversion
This commit is contained in:
@@ -14,7 +14,15 @@ import httpx
|
|||||||
ATOM_NS = "http://www.w3.org/2005/Atom"
|
ATOM_NS = "http://www.w3.org/2005/Atom"
|
||||||
OPDS_NS = "http://opds-spec.org/2010/catalog"
|
OPDS_NS = "http://opds-spec.org/2010/catalog"
|
||||||
DC_NS = "http://purl.org/dc/terms/"
|
DC_NS = "http://purl.org/dc/terms/"
|
||||||
NS = {"atom": ATOM_NS, "opds": OPDS_NS, "dc": DC_NS}
|
CALIBRE_CATALOG_NS = "http://calibre.kovidgoyal.net/2009/catalog"
|
||||||
|
CALIBRE_METADATA_NS = "http://calibre.kovidgoyal.net/2009/metadata"
|
||||||
|
NS = {
|
||||||
|
"atom": ATOM_NS,
|
||||||
|
"opds": OPDS_NS,
|
||||||
|
"dc": DC_NS,
|
||||||
|
"calibre": CALIBRE_CATALOG_NS,
|
||||||
|
"calibre_md": CALIBRE_METADATA_NS,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
_TAG_STRIP_RE = re.compile(r"<[^>]+>")
|
_TAG_STRIP_RE = re.compile(r"<[^>]+>")
|
||||||
@@ -58,6 +66,8 @@ class OPDSEntry:
|
|||||||
alternate: Optional[OPDSLink] = None
|
alternate: Optional[OPDSLink] = None
|
||||||
thumbnail: Optional[OPDSLink] = None
|
thumbnail: Optional[OPDSLink] = None
|
||||||
links: List[OPDSLink] = field(default_factory=list)
|
links: List[OPDSLink] = field(default_factory=list)
|
||||||
|
series: Optional[str] = None
|
||||||
|
series_index: Optional[float] = None
|
||||||
|
|
||||||
def to_dict(self) -> Dict[str, Any]:
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
@@ -71,6 +81,8 @@ class OPDSEntry:
|
|||||||
"alternate": self.alternate.to_dict() if self.alternate else None,
|
"alternate": self.alternate.to_dict() if self.alternate else None,
|
||||||
"thumbnail": self.thumbnail.to_dict() if self.thumbnail else None,
|
"thumbnail": self.thumbnail.to_dict() if self.thumbnail else None,
|
||||||
"links": [link.to_dict() for link in self.links],
|
"links": [link.to_dict() for link in self.links],
|
||||||
|
"series": self.series,
|
||||||
|
"series_index": self.series_index,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -272,6 +284,31 @@ class CalibreOPDSClient:
|
|||||||
thumb_link = parsed_links.get("http://opds-spec.org/image/thumbnail") or parsed_links.get(
|
thumb_link = parsed_links.get("http://opds-spec.org/image/thumbnail") or parsed_links.get(
|
||||||
"thumbnail"
|
"thumbnail"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
series_name = (
|
||||||
|
node.findtext("calibre:series", default=None, namespaces=NS)
|
||||||
|
or node.findtext("calibre_md:series", default=None, namespaces=NS)
|
||||||
|
)
|
||||||
|
if series_name:
|
||||||
|
series_name = series_name.strip() or None
|
||||||
|
|
||||||
|
series_index_raw = (
|
||||||
|
node.findtext("calibre:series_index", default=None, namespaces=NS)
|
||||||
|
or node.findtext("calibre_md:series_index", default=None, namespaces=NS)
|
||||||
|
)
|
||||||
|
series_index: Optional[float] = None
|
||||||
|
if series_index_raw is not None:
|
||||||
|
text = str(series_index_raw).strip()
|
||||||
|
if text:
|
||||||
|
try:
|
||||||
|
series_index = float(text)
|
||||||
|
except ValueError:
|
||||||
|
match = re.search(r"\d+(?:\.\d+)?", text.replace(",", "."))
|
||||||
|
if match:
|
||||||
|
try:
|
||||||
|
series_index = float(match.group(0))
|
||||||
|
except ValueError:
|
||||||
|
series_index = None
|
||||||
return OPDSEntry(
|
return OPDSEntry(
|
||||||
id=entry_id or title,
|
id=entry_id or title,
|
||||||
title=title,
|
title=title,
|
||||||
@@ -283,6 +320,8 @@ class CalibreOPDSClient:
|
|||||||
alternate=alternate_link,
|
alternate=alternate_link,
|
||||||
thumbnail=thumb_link,
|
thumbnail=thumb_link,
|
||||||
links=list(parsed_links.values()),
|
links=list(parsed_links.values()),
|
||||||
|
series=series_name,
|
||||||
|
series_index=series_index,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _extract_position(self, node: ET.Element) -> Optional[int]:
|
def _extract_position(self, node: ET.Element) -> Optional[int]:
|
||||||
|
|||||||
@@ -310,6 +310,82 @@ def _ensure_sentence(text: str) -> str:
|
|||||||
return f"{cleaned}."
|
return f"{cleaned}."
|
||||||
|
|
||||||
|
|
||||||
|
_SERIES_NAME_KEYS = (
|
||||||
|
"series",
|
||||||
|
"series_name",
|
||||||
|
"series_title",
|
||||||
|
)
|
||||||
|
_SERIES_NUMBER_KEYS = (
|
||||||
|
"series_index",
|
||||||
|
"series_position",
|
||||||
|
"series_sequence",
|
||||||
|
"book_number",
|
||||||
|
"series_number",
|
||||||
|
)
|
||||||
|
_SERIES_NUMBER_RE = re.compile(r"\d+(?:\.\d+)?")
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_series_number(value: Any) -> Optional[str]:
|
||||||
|
text = str(value or "").strip()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
candidate = text.replace(",", ".")
|
||||||
|
if candidate.replace(".", "", 1).isdigit():
|
||||||
|
if "." in candidate:
|
||||||
|
normalized = candidate.rstrip("0").rstrip(".")
|
||||||
|
return normalized or "0"
|
||||||
|
try:
|
||||||
|
return str(int(candidate))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
match = _SERIES_NUMBER_RE.search(candidate)
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
normalized = match.group(0)
|
||||||
|
if "." in normalized:
|
||||||
|
normalized = normalized.rstrip("0").rstrip(".")
|
||||||
|
return normalized or "0"
|
||||||
|
try:
|
||||||
|
return str(int(normalized))
|
||||||
|
except ValueError:
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_series_metadata(values: Mapping[str, str]) -> tuple[Optional[str], Optional[str]]:
|
||||||
|
series_name: Optional[str] = None
|
||||||
|
for key in _SERIES_NAME_KEYS:
|
||||||
|
raw = values.get(key)
|
||||||
|
if raw:
|
||||||
|
cleaned = str(raw).strip()
|
||||||
|
if cleaned:
|
||||||
|
series_name = cleaned
|
||||||
|
break
|
||||||
|
|
||||||
|
series_number: Optional[str] = None
|
||||||
|
for key in _SERIES_NUMBER_KEYS:
|
||||||
|
raw = values.get(key)
|
||||||
|
if raw is None:
|
||||||
|
continue
|
||||||
|
normalized = _normalize_series_number(raw)
|
||||||
|
if normalized:
|
||||||
|
series_number = normalized
|
||||||
|
break
|
||||||
|
|
||||||
|
return series_name, series_number
|
||||||
|
|
||||||
|
|
||||||
|
def _format_series_sentence(series_name: Optional[str], series_number: Optional[str]) -> str:
|
||||||
|
if not series_name or not series_number:
|
||||||
|
return ""
|
||||||
|
name = series_name.strip()
|
||||||
|
number = series_number.strip()
|
||||||
|
if not name or not number:
|
||||||
|
return ""
|
||||||
|
article = "the " if not name.lower().startswith("the ") else ""
|
||||||
|
phrase = f"Book {number} of {article}{name}"
|
||||||
|
return re.sub(r"\s+", " ", phrase).strip()
|
||||||
|
|
||||||
|
|
||||||
def _build_title_intro_text(
|
def _build_title_intro_text(
|
||||||
metadata: Optional[Mapping[str, Any]],
|
metadata: Optional[Mapping[str, Any]],
|
||||||
fallback_basename: str,
|
fallback_basename: str,
|
||||||
@@ -329,7 +405,12 @@ def _build_title_intro_text(
|
|||||||
author_value = value
|
author_value = value
|
||||||
break
|
break
|
||||||
|
|
||||||
|
series_name, series_number = _extract_series_metadata(normalized)
|
||||||
|
series_sentence = _format_series_sentence(series_name, series_number)
|
||||||
|
|
||||||
sentences: List[str] = []
|
sentences: List[str] = []
|
||||||
|
if series_sentence:
|
||||||
|
sentences.append(_ensure_sentence(series_sentence))
|
||||||
if title:
|
if title:
|
||||||
sentences.append(_ensure_sentence(title))
|
sentences.append(_ensure_sentence(title))
|
||||||
if subtitle:
|
if subtitle:
|
||||||
@@ -360,13 +441,24 @@ def _build_outro_text(
|
|||||||
break
|
break
|
||||||
author_sentence = _format_author_sentence(author_value)
|
author_sentence = _format_author_sentence(author_value)
|
||||||
authors_fragment = author_sentence[3:].strip() if author_sentence.lower().startswith("by ") else author_sentence.strip()
|
authors_fragment = author_sentence[3:].strip() if author_sentence.lower().startswith("by ") else author_sentence.strip()
|
||||||
|
|
||||||
if title and authors_fragment:
|
if title and authors_fragment:
|
||||||
return f"The end of {title} from {authors_fragment}."
|
closing_line = f"The end of {title} from {authors_fragment}"
|
||||||
if title:
|
elif title:
|
||||||
return f"The end of {title}."
|
closing_line = f"The end of {title}"
|
||||||
if authors_fragment:
|
elif authors_fragment:
|
||||||
return f"The end from {authors_fragment}."
|
closing_line = f"The end from {authors_fragment}"
|
||||||
return "The end."
|
else:
|
||||||
|
closing_line = "The end"
|
||||||
|
|
||||||
|
series_name, series_number = _extract_series_metadata(normalized)
|
||||||
|
series_sentence = _format_series_sentence(series_name, series_number)
|
||||||
|
|
||||||
|
sentences: List[str] = [_ensure_sentence(closing_line)]
|
||||||
|
if series_sentence:
|
||||||
|
sentences.append(_ensure_sentence(series_sentence))
|
||||||
|
|
||||||
|
return " ".join(sentence for sentence in sentences if sentence).strip()
|
||||||
|
|
||||||
|
|
||||||
def _spec_to_voice_ids(spec: Any) -> Set[str]:
|
def _spec_to_voice_ids(spec: Any) -> Set[str]:
|
||||||
@@ -1661,6 +1753,7 @@ def run_conversion_job(job: Job) -> None:
|
|||||||
marker["original_title"] = raw_title
|
marker["original_title"] = raw_title
|
||||||
chapter_markers.append(marker)
|
chapter_markers.append(marker)
|
||||||
|
|
||||||
|
if getattr(job, "read_closing_outro", True):
|
||||||
outro_text = _build_outro_text(job.metadata_tags, job.original_filename)
|
outro_text = _build_outro_text(job.metadata_tags, job.original_filename)
|
||||||
outro_voice_spec = base_voice_spec or job.voice
|
outro_voice_spec = base_voice_spec or job.voice
|
||||||
if outro_voice_spec == "__custom_mix":
|
if outro_voice_spec == "__custom_mix":
|
||||||
|
|||||||
+83
-1
@@ -1559,6 +1559,22 @@ def _apply_prepare_form(
|
|||||||
elif hasattr(form, "__contains__") and "read_title_intro" in form:
|
elif hasattr(form, "__contains__") and "read_title_intro" in form:
|
||||||
pending.read_title_intro = False
|
pending.read_title_intro = False
|
||||||
|
|
||||||
|
outro_values: List[str] = []
|
||||||
|
if callable(getter):
|
||||||
|
raw_outro_values = getter("read_closing_outro")
|
||||||
|
if raw_outro_values:
|
||||||
|
outro_values = list(cast(Iterable[str], raw_outro_values))
|
||||||
|
else:
|
||||||
|
raw_outro = form.get("read_closing_outro")
|
||||||
|
if raw_outro is not None:
|
||||||
|
outro_values = [raw_outro]
|
||||||
|
if outro_values:
|
||||||
|
pending.read_closing_outro = _coerce_bool(
|
||||||
|
outro_values[-1], getattr(pending, "read_closing_outro", True)
|
||||||
|
)
|
||||||
|
elif hasattr(form, "__contains__") and "read_closing_outro" in form:
|
||||||
|
pending.read_closing_outro = False
|
||||||
|
|
||||||
caps_values: List[str] = []
|
caps_values: List[str] = []
|
||||||
if callable(getter):
|
if callable(getter):
|
||||||
raw_caps_values = getter("normalize_chapter_opening_caps")
|
raw_caps_values = getter("normalize_chapter_opening_caps")
|
||||||
@@ -1701,6 +1717,27 @@ def _apply_book_step_form(
|
|||||||
else:
|
else:
|
||||||
pending.read_title_intro = intro_default
|
pending.read_title_intro = intro_default
|
||||||
|
|
||||||
|
outro_default = (
|
||||||
|
pending.read_closing_outro
|
||||||
|
if isinstance(getattr(pending, "read_closing_outro", None), bool)
|
||||||
|
else bool(settings.get("read_closing_outro", True))
|
||||||
|
)
|
||||||
|
outro_values: List[str] = []
|
||||||
|
if callable(getter):
|
||||||
|
raw_outro_values = getter("read_closing_outro")
|
||||||
|
if raw_outro_values:
|
||||||
|
outro_values = list(cast(Iterable[str], raw_outro_values))
|
||||||
|
else:
|
||||||
|
raw_outro_flag = form.get("read_closing_outro")
|
||||||
|
if raw_outro_flag is not None:
|
||||||
|
outro_values = [raw_outro_flag]
|
||||||
|
if outro_values:
|
||||||
|
pending.read_closing_outro = _coerce_bool(outro_values[-1], outro_default)
|
||||||
|
elif hasattr(form, "__contains__") and "read_closing_outro" in form:
|
||||||
|
pending.read_closing_outro = False
|
||||||
|
else:
|
||||||
|
pending.read_closing_outro = outro_default
|
||||||
|
|
||||||
caps_default = (
|
caps_default = (
|
||||||
pending.normalize_chapter_opening_caps
|
pending.normalize_chapter_opening_caps
|
||||||
if isinstance(getattr(pending, "normalize_chapter_opening_caps", None), bool)
|
if isinstance(getattr(pending, "normalize_chapter_opening_caps", None), bool)
|
||||||
@@ -2051,6 +2088,7 @@ BOOLEAN_SETTINGS = {
|
|||||||
"generate_epub3",
|
"generate_epub3",
|
||||||
"enable_entity_recognition",
|
"enable_entity_recognition",
|
||||||
"read_title_intro",
|
"read_title_intro",
|
||||||
|
"read_closing_outro",
|
||||||
"auto_prefix_chapter_titles",
|
"auto_prefix_chapter_titles",
|
||||||
"normalize_chapter_opening_caps",
|
"normalize_chapter_opening_caps",
|
||||||
"normalization_numbers",
|
"normalization_numbers",
|
||||||
@@ -2121,6 +2159,7 @@ def _settings_defaults() -> Dict[str, Any]:
|
|||||||
"silence_between_chapters": 2.0,
|
"silence_between_chapters": 2.0,
|
||||||
"chapter_intro_delay": 0.5,
|
"chapter_intro_delay": 0.5,
|
||||||
"read_title_intro": False,
|
"read_title_intro": False,
|
||||||
|
"read_closing_outro": True,
|
||||||
"normalize_chapter_opening_caps": True,
|
"normalize_chapter_opening_caps": True,
|
||||||
"max_subtitle_words": 50,
|
"max_subtitle_words": 50,
|
||||||
"chunk_level": "paragraph",
|
"chunk_level": "paragraph",
|
||||||
@@ -2869,6 +2908,28 @@ def calibre_opds_import() -> ResponseReturnValue:
|
|||||||
if not href:
|
if not href:
|
||||||
return jsonify({"error": "Download link missing."}), 400
|
return jsonify({"error": "Download link missing."}), 400
|
||||||
|
|
||||||
|
metadata_payload = data.get("metadata") if isinstance(data, Mapping) else None
|
||||||
|
metadata_overrides: Dict[str, Any] = {}
|
||||||
|
if isinstance(metadata_payload, Mapping):
|
||||||
|
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)
|
||||||
|
|
||||||
stored_settings = _stored_integration_config("calibre_opds")
|
stored_settings = _stored_integration_config("calibre_opds")
|
||||||
if not stored_settings or not _coerce_bool(stored_settings.get("enabled"), False):
|
if not stored_settings or not _coerce_bool(stored_settings.get("enabled"), False):
|
||||||
return jsonify({"error": "Calibre OPDS integration is not enabled."}), 400
|
return jsonify({"error": "Calibre OPDS integration is not enabled."}), 400
|
||||||
@@ -2915,6 +2976,7 @@ def calibre_opds_import() -> ResponseReturnValue:
|
|||||||
form=MultiDict(),
|
form=MultiDict(),
|
||||||
settings=settings,
|
settings=settings,
|
||||||
profiles=profiles,
|
profiles=profiles,
|
||||||
|
metadata_overrides=metadata_overrides or None,
|
||||||
)
|
)
|
||||||
|
|
||||||
pending = build_result.pending
|
pending = build_result.pending
|
||||||
@@ -4059,10 +4121,27 @@ def _build_pending_job_from_extraction(
|
|||||||
|
|
||||||
metadata_tags = dict(getattr(extraction, "metadata", {}) or {})
|
metadata_tags = dict(getattr(extraction, "metadata", {}) or {})
|
||||||
if metadata_overrides:
|
if metadata_overrides:
|
||||||
|
normalized_keys = {str(existing_key).casefold(): str(existing_key) for existing_key in metadata_tags.keys()}
|
||||||
for key, value in metadata_overrides.items():
|
for key, value in metadata_overrides.items():
|
||||||
if value is None:
|
if value is None:
|
||||||
continue
|
continue
|
||||||
metadata_tags[str(key)] = str(value)
|
key_text = str(key or "").strip()
|
||||||
|
if not key_text:
|
||||||
|
continue
|
||||||
|
value_text = str(value).strip()
|
||||||
|
if not value_text:
|
||||||
|
continue
|
||||||
|
lookup = key_text.casefold()
|
||||||
|
existing_key = normalized_keys.get(lookup)
|
||||||
|
if existing_key:
|
||||||
|
existing_value = str(metadata_tags.get(existing_key) or "").strip()
|
||||||
|
if existing_value:
|
||||||
|
continue
|
||||||
|
target_key = existing_key
|
||||||
|
else:
|
||||||
|
target_key = key_text
|
||||||
|
normalized_keys[lookup] = target_key
|
||||||
|
metadata_tags[target_key] = value_text
|
||||||
|
|
||||||
total_chars = getattr(extraction, "total_characters", None) or calculate_text_length(
|
total_chars = getattr(extraction, "total_characters", None) or calculate_text_length(
|
||||||
getattr(extraction, "combined_text", "")
|
getattr(extraction, "combined_text", "")
|
||||||
@@ -4154,6 +4233,7 @@ def _build_pending_job_from_extraction(
|
|||||||
silence_between_chapters = settings["silence_between_chapters"]
|
silence_between_chapters = settings["silence_between_chapters"]
|
||||||
chapter_intro_delay = settings["chapter_intro_delay"]
|
chapter_intro_delay = settings["chapter_intro_delay"]
|
||||||
read_title_intro = settings["read_title_intro"]
|
read_title_intro = settings["read_title_intro"]
|
||||||
|
read_closing_outro = settings.get("read_closing_outro", True)
|
||||||
normalize_chapter_opening_caps = settings["normalize_chapter_opening_caps"]
|
normalize_chapter_opening_caps = settings["normalize_chapter_opening_caps"]
|
||||||
max_subtitle_words = settings["max_subtitle_words"]
|
max_subtitle_words = settings["max_subtitle_words"]
|
||||||
auto_prefix_chapter_titles = settings["auto_prefix_chapter_titles"]
|
auto_prefix_chapter_titles = settings["auto_prefix_chapter_titles"]
|
||||||
@@ -4232,6 +4312,7 @@ def _build_pending_job_from_extraction(
|
|||||||
cover_image_mime=cover_mime,
|
cover_image_mime=cover_mime,
|
||||||
chapter_intro_delay=chapter_intro_delay,
|
chapter_intro_delay=chapter_intro_delay,
|
||||||
read_title_intro=bool(read_title_intro),
|
read_title_intro=bool(read_title_intro),
|
||||||
|
read_closing_outro=bool(read_closing_outro),
|
||||||
normalize_chapter_opening_caps=bool(normalize_chapter_opening_caps),
|
normalize_chapter_opening_caps=bool(normalize_chapter_opening_caps),
|
||||||
auto_prefix_chapter_titles=bool(auto_prefix_chapter_titles),
|
auto_prefix_chapter_titles=bool(auto_prefix_chapter_titles),
|
||||||
chunk_level=chunk_level_value,
|
chunk_level=chunk_level_value,
|
||||||
@@ -4593,6 +4674,7 @@ def finalize_job(pending_id: str) -> ResponseReturnValue:
|
|||||||
cover_image_mime=pending.cover_image_mime,
|
cover_image_mime=pending.cover_image_mime,
|
||||||
chapter_intro_delay=pending.chapter_intro_delay,
|
chapter_intro_delay=pending.chapter_intro_delay,
|
||||||
read_title_intro=pending.read_title_intro,
|
read_title_intro=pending.read_title_intro,
|
||||||
|
read_closing_outro=getattr(pending, "read_closing_outro", True),
|
||||||
normalize_chapter_opening_caps=getattr(pending, "normalize_chapter_opening_caps", True),
|
normalize_chapter_opening_caps=getattr(pending, "normalize_chapter_opening_caps", True),
|
||||||
auto_prefix_chapter_titles=getattr(pending, "auto_prefix_chapter_titles", True),
|
auto_prefix_chapter_titles=getattr(pending, "auto_prefix_chapter_titles", True),
|
||||||
chunk_level=pending.chunk_level,
|
chunk_level=pending.chunk_level,
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ class Job:
|
|||||||
max_subtitle_words: int = 50
|
max_subtitle_words: int = 50
|
||||||
chapter_intro_delay: float = 0.5
|
chapter_intro_delay: float = 0.5
|
||||||
read_title_intro: bool = False
|
read_title_intro: bool = False
|
||||||
|
read_closing_outro: bool = True
|
||||||
auto_prefix_chapter_titles: bool = True
|
auto_prefix_chapter_titles: bool = True
|
||||||
normalize_chapter_opening_caps: bool = True
|
normalize_chapter_opening_caps: bool = True
|
||||||
status: JobStatus = JobStatus.PENDING
|
status: JobStatus = JobStatus.PENDING
|
||||||
@@ -185,6 +186,7 @@ class Job:
|
|||||||
"max_subtitle_words": self.max_subtitle_words,
|
"max_subtitle_words": self.max_subtitle_words,
|
||||||
"chapter_intro_delay": self.chapter_intro_delay,
|
"chapter_intro_delay": self.chapter_intro_delay,
|
||||||
"read_title_intro": getattr(self, "read_title_intro", False),
|
"read_title_intro": getattr(self, "read_title_intro", False),
|
||||||
|
"read_closing_outro": getattr(self, "read_closing_outro", True),
|
||||||
"auto_prefix_chapter_titles": getattr(self, "auto_prefix_chapter_titles", True),
|
"auto_prefix_chapter_titles": getattr(self, "auto_prefix_chapter_titles", True),
|
||||||
"normalize_chapter_opening_caps": getattr(self, "normalize_chapter_opening_caps", True),
|
"normalize_chapter_opening_caps": getattr(self, "normalize_chapter_opening_caps", True),
|
||||||
},
|
},
|
||||||
@@ -439,6 +441,7 @@ class PendingJob:
|
|||||||
cover_image_mime: Optional[str] = None
|
cover_image_mime: Optional[str] = None
|
||||||
chapter_intro_delay: float = 0.5
|
chapter_intro_delay: float = 0.5
|
||||||
read_title_intro: bool = False
|
read_title_intro: bool = False
|
||||||
|
read_closing_outro: bool = True
|
||||||
auto_prefix_chapter_titles: bool = True
|
auto_prefix_chapter_titles: bool = True
|
||||||
normalize_chapter_opening_caps: bool = True
|
normalize_chapter_opening_caps: bool = True
|
||||||
chunk_level: str = "paragraph"
|
chunk_level: str = "paragraph"
|
||||||
@@ -521,6 +524,7 @@ class ConversionService:
|
|||||||
cover_image_mime: Optional[str] = None,
|
cover_image_mime: Optional[str] = None,
|
||||||
chapter_intro_delay: float = 0.5,
|
chapter_intro_delay: float = 0.5,
|
||||||
read_title_intro: bool = False,
|
read_title_intro: bool = False,
|
||||||
|
read_closing_outro: bool = True,
|
||||||
auto_prefix_chapter_titles: bool = True,
|
auto_prefix_chapter_titles: bool = True,
|
||||||
normalize_chapter_opening_caps: bool = True,
|
normalize_chapter_opening_caps: bool = True,
|
||||||
chunk_level: str = "paragraph",
|
chunk_level: str = "paragraph",
|
||||||
@@ -571,6 +575,7 @@ class ConversionService:
|
|||||||
cover_image_mime=cover_image_mime,
|
cover_image_mime=cover_image_mime,
|
||||||
chapter_intro_delay=chapter_intro_delay,
|
chapter_intro_delay=chapter_intro_delay,
|
||||||
read_title_intro=bool(read_title_intro),
|
read_title_intro=bool(read_title_intro),
|
||||||
|
read_closing_outro=bool(read_closing_outro),
|
||||||
auto_prefix_chapter_titles=bool(auto_prefix_chapter_titles),
|
auto_prefix_chapter_titles=bool(auto_prefix_chapter_titles),
|
||||||
normalize_chapter_opening_caps=bool(normalize_chapter_opening_caps),
|
normalize_chapter_opening_caps=bool(normalize_chapter_opening_caps),
|
||||||
chunk_level=chunk_level,
|
chunk_level=chunk_level,
|
||||||
|
|||||||
@@ -364,10 +364,28 @@ if (modal && browser) {
|
|||||||
}
|
}
|
||||||
setStatus('Downloading book from Calibre. This can take a minute…', 'loading');
|
setStatus('Downloading book from Calibre. This can take a minute…', 'loading');
|
||||||
try {
|
try {
|
||||||
|
const requestPayload = {
|
||||||
|
href: entry.download.href,
|
||||||
|
title: entry.title || '',
|
||||||
|
};
|
||||||
|
const metadata = {};
|
||||||
|
if (entry.series) {
|
||||||
|
metadata.series = entry.series;
|
||||||
|
metadata.series_name = entry.series;
|
||||||
|
}
|
||||||
|
const seriesIndex = entry.series_index ?? entry.seriesIndex ?? null;
|
||||||
|
if (seriesIndex !== null && seriesIndex !== undefined && seriesIndex !== '') {
|
||||||
|
metadata.series_index = seriesIndex;
|
||||||
|
metadata.series_position = seriesIndex;
|
||||||
|
metadata.book_number = seriesIndex;
|
||||||
|
}
|
||||||
|
if (Object.keys(metadata).length > 0) {
|
||||||
|
requestPayload.metadata = metadata;
|
||||||
|
}
|
||||||
const response = await fetch('/api/integrations/calibre-opds/import', {
|
const response = await fetch('/api/integrations/calibre-opds/import', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ href: entry.download.href, title: entry.title || '' }),
|
body: JSON.stringify(requestPayload),
|
||||||
});
|
});
|
||||||
const payload = await response.json();
|
const payload = await response.json();
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
<li><strong>Silence between chapters:</strong> {{ '%.1f'|format(job.silence_between_chapters) }}s</li>
|
<li><strong>Silence between chapters:</strong> {{ '%.1f'|format(job.silence_between_chapters) }}s</li>
|
||||||
<li><strong>Chapter intro delay:</strong> {{ '%.1f'|format(job.chapter_intro_delay) }}s</li>
|
<li><strong>Chapter intro delay:</strong> {{ '%.1f'|format(job.chapter_intro_delay) }}s</li>
|
||||||
<li><strong>Title intro:</strong> {{ 'Yes' if job.read_title_intro else 'No' }}</li>
|
<li><strong>Title intro:</strong> {{ 'Yes' if job.read_title_intro else 'No' }}</li>
|
||||||
|
<li><strong>Closing outro:</strong> {{ 'Yes' if job.read_closing_outro else 'No' }}</li>
|
||||||
<li><strong>Normalize chapter openings:</strong> {{ 'Yes' if job.normalize_chapter_opening_caps else 'No' }}</li>
|
<li><strong>Normalize chapter openings:</strong> {{ 'Yes' if job.normalize_chapter_opening_caps else 'No' }}</li>
|
||||||
<li><strong>Prefix chapter titles:</strong> {{ 'Yes' if job.auto_prefix_chapter_titles else 'No' }}</li>
|
<li><strong>Prefix chapter titles:</strong> {{ 'Yes' if job.auto_prefix_chapter_titles else 'No' }}</li>
|
||||||
<li><strong>Max words per subtitle:</strong> {{ job.max_subtitle_words }}</li>
|
<li><strong>Max words per subtitle:</strong> {{ job.max_subtitle_words }}</li>
|
||||||
|
|||||||
@@ -65,6 +65,16 @@
|
|||||||
{% set read_title_intro = settings_dict.get('read_title_intro', False) %}
|
{% set read_title_intro = settings_dict.get('read_title_intro', False) %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% set read_outro_value = form_values.get('read_closing_outro') if form_values else None %}
|
||||||
|
{% if read_outro_value is not none %}
|
||||||
|
{% set read_closing_outro = ((read_outro_value|string)|lower) in ['true', '1', 'yes', 'on'] %}
|
||||||
|
{% else %}
|
||||||
|
{% if pending is not none %}
|
||||||
|
{% set read_closing_outro = pending.read_closing_outro %}
|
||||||
|
{% else %}
|
||||||
|
{% set read_closing_outro = settings_dict.get('read_closing_outro', True) %}
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
{% set normalize_caps_value = form_values.get('normalize_chapter_opening_caps') if form_values else None %}
|
{% set normalize_caps_value = form_values.get('normalize_chapter_opening_caps') if form_values else None %}
|
||||||
{% if normalize_caps_value is not none %}
|
{% if normalize_caps_value is not none %}
|
||||||
{% set normalize_chapter_opening_caps = ((normalize_caps_value|string)|lower) in ['true', '1', 'yes', 'on'] %}
|
{% set normalize_chapter_opening_caps = ((normalize_caps_value|string)|lower) in ['true', '1', 'yes', 'on'] %}
|
||||||
@@ -340,6 +350,14 @@
|
|||||||
</label>
|
</label>
|
||||||
<p class="hint">When enabled, the narrator speaks the book title and author list before chapter one begins.</p>
|
<p class="hint">When enabled, the narrator speaks the book title and author list before chapter one begins.</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="field field--stack">
|
||||||
|
<label class="toggle-pill">
|
||||||
|
<input type="hidden" name="read_closing_outro" value="false">
|
||||||
|
<input type="checkbox" name="read_closing_outro" value="true" {% if read_closing_outro %}checked{% endif %} {{ 'disabled' if readonly else '' }}>
|
||||||
|
<span>Read closing outro after narration</span>
|
||||||
|
</label>
|
||||||
|
<p class="hint">Adds a short "The end" statement after the final chapter, including series details when available.</p>
|
||||||
|
</div>
|
||||||
<div class="field field--stack">
|
<div class="field field--stack">
|
||||||
<label class="toggle-pill">
|
<label class="toggle-pill">
|
||||||
<input type="hidden" name="normalize_chapter_opening_caps" value="false">
|
<input type="hidden" name="normalize_chapter_opening_caps" value="false">
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
<input type="hidden" name="speaker_analysis_threshold" value="{{ pending.speaker_analysis_threshold }}">
|
<input type="hidden" name="speaker_analysis_threshold" value="{{ pending.speaker_analysis_threshold }}">
|
||||||
<input type="hidden" name="chapter_intro_delay" value="{{ '%.2f'|format(pending.chapter_intro_delay) }}">
|
<input type="hidden" name="chapter_intro_delay" value="{{ '%.2f'|format(pending.chapter_intro_delay) }}">
|
||||||
<input type="hidden" name="read_title_intro" value="{{ 'true' if pending.read_title_intro else 'false' }}">
|
<input type="hidden" name="read_title_intro" value="{{ 'true' if pending.read_title_intro else 'false' }}">
|
||||||
|
<input type="hidden" name="read_closing_outro" value="{{ 'true' if pending.read_closing_outro else 'false' }}">
|
||||||
<input type="hidden" name="normalize_chapter_opening_caps" value="{{ 'true' if pending.normalize_chapter_opening_caps else 'false' }}">
|
<input type="hidden" name="normalize_chapter_opening_caps" value="{{ 'true' if pending.normalize_chapter_opening_caps else 'false' }}">
|
||||||
{% if pending.generate_epub3 %}
|
{% if pending.generate_epub3 %}
|
||||||
<input type="hidden" name="generate_epub3" value="true">
|
<input type="hidden" name="generate_epub3" value="true">
|
||||||
|
|||||||
@@ -53,6 +53,7 @@
|
|||||||
<input type="hidden" name="speaker_analysis_threshold" value="{{ pending.speaker_analysis_threshold }}">
|
<input type="hidden" name="speaker_analysis_threshold" value="{{ pending.speaker_analysis_threshold }}">
|
||||||
<input type="hidden" name="chapter_intro_delay" value="{{ '%.2f'|format(pending.chapter_intro_delay) }}">
|
<input type="hidden" name="chapter_intro_delay" value="{{ '%.2f'|format(pending.chapter_intro_delay) }}">
|
||||||
<input type="hidden" name="read_title_intro" value="{{ 'true' if pending.read_title_intro else 'false' }}">
|
<input type="hidden" name="read_title_intro" value="{{ 'true' if pending.read_title_intro else 'false' }}">
|
||||||
|
<input type="hidden" name="read_closing_outro" value="{{ 'true' if pending.read_closing_outro else 'false' }}">
|
||||||
<input type="hidden" name="normalize_chapter_opening_caps" value="{{ 'true' if pending.normalize_chapter_opening_caps else 'false' }}">
|
<input type="hidden" name="normalize_chapter_opening_caps" value="{{ 'true' if pending.normalize_chapter_opening_caps else 'false' }}">
|
||||||
{% if pending.generate_epub3 %}
|
{% if pending.generate_epub3 %}
|
||||||
<input type="hidden" name="generate_epub3" value="true">
|
<input type="hidden" name="generate_epub3" value="true">
|
||||||
|
|||||||
@@ -157,6 +157,13 @@
|
|||||||
</label>
|
</label>
|
||||||
<p class="hint">When enabled, the narrator speaks the title, optional subtitle, and author names before chapter one.</p>
|
<p class="hint">When enabled, the narrator speaks the title, optional subtitle, and author names before chapter one.</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="field field--stack">
|
||||||
|
<label class="toggle-pill">
|
||||||
|
<input type="checkbox" name="read_closing_outro" value="true" {% if settings.read_closing_outro %}checked{% endif %}>
|
||||||
|
<span>Read Closing Outro After Narration</span>
|
||||||
|
</label>
|
||||||
|
<p class="hint">Adds a brief "The end" line after the final chapter, optionally including series information.</p>
|
||||||
|
</div>
|
||||||
<div class="field field--stack">
|
<div class="field field--stack">
|
||||||
<label class="toggle-pill">
|
<label class="toggle-pill">
|
||||||
<input type="checkbox" name="normalize_chapter_opening_caps" value="true" {% if settings.normalize_chapter_opening_caps %}checked{% endif %}>
|
<input type="checkbox" name="normalize_chapter_opening_caps" value="true" {% if settings.normalize_chapter_opening_caps %}checked{% endif %}>
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
from abogen.integrations.calibre_opds import CalibreOPDSClient, feed_to_dict
|
||||||
|
|
||||||
|
|
||||||
|
def test_calibre_opds_feed_exposes_series_metadata() -> None:
|
||||||
|
client = CalibreOPDSClient("http://example.com/catalog")
|
||||||
|
xml_payload = """<?xml version=\"1.0\" encoding=\"UTF-8\"?>
|
||||||
|
<feed xmlns=\"http://www.w3.org/2005/Atom\"
|
||||||
|
xmlns:dc=\"http://purl.org/dc/terms/\"
|
||||||
|
xmlns:calibre=\"http://calibre.kovidgoyal.net/2009/catalog\">
|
||||||
|
<id>catalog</id>
|
||||||
|
<title>Example Catalog</title>
|
||||||
|
<entry>
|
||||||
|
<id>book-1</id>
|
||||||
|
<title>Sample Book</title>
|
||||||
|
<calibre:series>The Expanse</calibre:series>
|
||||||
|
<calibre:series_index>4</calibre:series_index>
|
||||||
|
<link rel=\"http://opds-spec.org/acquisition\"
|
||||||
|
href=\"books/sample.epub\"
|
||||||
|
type=\"application/epub+zip\" />
|
||||||
|
</entry>
|
||||||
|
</feed>
|
||||||
|
"""
|
||||||
|
|
||||||
|
feed = client._parse_feed(xml_payload, base_url="http://example.com/catalog")
|
||||||
|
assert feed.entries, "Expected at least one entry in parsed feed"
|
||||||
|
entry = feed.entries[0]
|
||||||
|
|
||||||
|
assert entry.series == "The Expanse"
|
||||||
|
assert entry.series_index == 4.0
|
||||||
|
|
||||||
|
feed_dict = feed_to_dict(feed)
|
||||||
|
assert feed_dict["entries"][0]["series"] == "The Expanse"
|
||||||
|
assert feed_dict["entries"][0]["series_index"] == 4.0
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import sys
|
||||||
|
import types
|
||||||
|
|
||||||
|
if "soundfile" not in sys.modules:
|
||||||
|
soundfile_stub = types.ModuleType("soundfile")
|
||||||
|
|
||||||
|
class _SoundFileStub: # pragma: no cover - placeholder to satisfy imports
|
||||||
|
def __init__(self, *args: object, **kwargs: object) -> None:
|
||||||
|
raise RuntimeError("soundfile is not installed in the test environment")
|
||||||
|
|
||||||
|
soundfile_stub.SoundFile = _SoundFileStub # type: ignore[attr-defined]
|
||||||
|
sys.modules["soundfile"] = soundfile_stub
|
||||||
|
|
||||||
|
if "static_ffmpeg" not in sys.modules:
|
||||||
|
sys.modules["static_ffmpeg"] = types.ModuleType("static_ffmpeg")
|
||||||
|
|
||||||
|
if "ebooklib" not in sys.modules:
|
||||||
|
ebooklib_stub = types.ModuleType("ebooklib")
|
||||||
|
ebooklib_epub_stub = types.ModuleType("ebooklib.epub")
|
||||||
|
ebooklib_stub.epub = ebooklib_epub_stub # type: ignore[attr-defined]
|
||||||
|
sys.modules["ebooklib"] = ebooklib_stub
|
||||||
|
sys.modules["ebooklib.epub"] = ebooklib_epub_stub
|
||||||
|
|
||||||
|
if "fitz" not in sys.modules:
|
||||||
|
sys.modules["fitz"] = types.ModuleType("fitz")
|
||||||
|
|
||||||
|
if "markdown" not in sys.modules:
|
||||||
|
markdown_stub = types.ModuleType("markdown")
|
||||||
|
|
||||||
|
class _MarkdownStub:
|
||||||
|
def __init__(self, *args: object, **kwargs: object) -> None:
|
||||||
|
self.toc_tokens = []
|
||||||
|
|
||||||
|
def convert(self, text: str) -> str:
|
||||||
|
return text
|
||||||
|
|
||||||
|
markdown_stub.Markdown = _MarkdownStub # type: ignore[attr-defined]
|
||||||
|
sys.modules["markdown"] = markdown_stub
|
||||||
|
|
||||||
|
if "bs4" not in sys.modules:
|
||||||
|
bs4_stub = types.ModuleType("bs4")
|
||||||
|
|
||||||
|
class _BeautifulSoupStub:
|
||||||
|
def __init__(self, *args: object, **kwargs: object) -> None:
|
||||||
|
self._text = ""
|
||||||
|
|
||||||
|
def find(self, *args: object, **kwargs: object) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_text(self) -> str:
|
||||||
|
return self._text
|
||||||
|
|
||||||
|
def decompose(self) -> None: # pragma: no cover - compatibility shim
|
||||||
|
return None
|
||||||
|
|
||||||
|
class _NavigableStringStub(str):
|
||||||
|
pass
|
||||||
|
|
||||||
|
bs4_stub.BeautifulSoup = _BeautifulSoupStub # type: ignore[attr-defined]
|
||||||
|
bs4_stub.NavigableString = _NavigableStringStub # type: ignore[attr-defined]
|
||||||
|
sys.modules["bs4"] = bs4_stub
|
||||||
|
|
||||||
|
|
||||||
|
from abogen.web.conversion_runner import _build_outro_text, _build_title_intro_text
|
||||||
|
|
||||||
|
|
||||||
|
def test_title_intro_includes_series_sentence() -> None:
|
||||||
|
metadata = {
|
||||||
|
"title": "Galactic Chronicles",
|
||||||
|
"author": "Jane Doe",
|
||||||
|
"series": "Chronicles",
|
||||||
|
"series_index": "2",
|
||||||
|
}
|
||||||
|
|
||||||
|
intro_text = _build_title_intro_text(metadata, "chronicles.mp3")
|
||||||
|
|
||||||
|
assert intro_text.startswith("Book 2 of the Chronicles.")
|
||||||
|
assert "Galactic Chronicles." in intro_text
|
||||||
|
assert "By Jane Doe." in intro_text
|
||||||
|
|
||||||
|
|
||||||
|
def test_series_sentence_skips_duplicate_article() -> None:
|
||||||
|
metadata = {
|
||||||
|
"title": "Iron Council",
|
||||||
|
"authors": "China Miéville",
|
||||||
|
"series": "The Bas-Lag",
|
||||||
|
"series_index": "3",
|
||||||
|
}
|
||||||
|
|
||||||
|
intro_text = _build_title_intro_text(metadata, "iron_council.mp3")
|
||||||
|
|
||||||
|
assert "Book 3 of The Bas-Lag." in intro_text
|
||||||
|
assert "of the The" not in intro_text
|
||||||
|
|
||||||
|
|
||||||
|
def test_outro_appends_series_information() -> None:
|
||||||
|
metadata = {
|
||||||
|
"title": "Abaddon's Gate",
|
||||||
|
"authors": "James S. A. Corey",
|
||||||
|
"series": "The Expanse",
|
||||||
|
"series_index": "3",
|
||||||
|
}
|
||||||
|
|
||||||
|
outro_text = _build_outro_text(metadata, "abaddon.mp3")
|
||||||
|
|
||||||
|
assert outro_text.startswith("The end of Abaddon's Gate from James S. A. Corey.")
|
||||||
|
assert outro_text.endswith("Book 3 of The Expanse.")
|
||||||
|
|
||||||
|
|
||||||
|
def test_series_number_preserves_decimal_positions() -> None:
|
||||||
|
metadata = {
|
||||||
|
"title": "Interlude",
|
||||||
|
"author": "Alex Writer",
|
||||||
|
"series": "Chronicles",
|
||||||
|
"series_index": "2.5",
|
||||||
|
}
|
||||||
|
|
||||||
|
intro_text = _build_title_intro_text(metadata, "interlude.mp3")
|
||||||
|
|
||||||
|
assert "Book 2.5 of the Chronicles." in intro_text
|
||||||
@@ -80,3 +80,15 @@ def test_resolve_voice_setting_handles_profile_reference():
|
|||||||
assert voice == "af_nova*0.5+am_liam*0.5"
|
assert voice == "af_nova*0.5+am_liam*0.5"
|
||||||
assert profile_name == "Blend"
|
assert profile_name == "Blend"
|
||||||
assert language == "b"
|
assert language == "b"
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_prepare_form_updates_closing_outro_flag():
|
||||||
|
pending = _make_pending_job()
|
||||||
|
pending.read_closing_outro = True
|
||||||
|
form = MultiDict({
|
||||||
|
"read_closing_outro": "false",
|
||||||
|
})
|
||||||
|
|
||||||
|
_apply_prepare_form(pending, form)
|
||||||
|
|
||||||
|
assert pending.read_closing_outro is False
|
||||||
|
|||||||
Reference in New Issue
Block a user