mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 13:40:27 +02:00
feat: Implement existing item lookup and overwrite handling for Audiobookshelf uploads
This commit is contained in:
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import mimetypes
|
import mimetypes
|
||||||
|
import re
|
||||||
from contextlib import ExitStack
|
from contextlib import ExitStack
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -180,6 +181,159 @@ class AudiobookshelfClient:
|
|||||||
files.append((field_name, (path.name, handle, mime_type)))
|
files.append((field_name, (path.name, handle, mime_type)))
|
||||||
return files
|
return files
|
||||||
|
|
||||||
|
def find_existing_items(
|
||||||
|
self,
|
||||||
|
title: str,
|
||||||
|
*,
|
||||||
|
folder_id: Optional[str] = None,
|
||||||
|
) -> List[Mapping[str, Any]]:
|
||||||
|
normalized_title = self._normalize_title_value(title)
|
||||||
|
if not normalized_title:
|
||||||
|
return []
|
||||||
|
|
||||||
|
folder_hint = folder_id or self._config.folder_id
|
||||||
|
target_folders = set()
|
||||||
|
if folder_hint:
|
||||||
|
folder_token = str(folder_hint).strip().lower()
|
||||||
|
if folder_token:
|
||||||
|
target_folders.add(folder_token)
|
||||||
|
|
||||||
|
requests = self._candidate_search_requests(title, folder_hint)
|
||||||
|
if not requests:
|
||||||
|
return []
|
||||||
|
|
||||||
|
matches: List[Mapping[str, Any]] = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
with self._open_client() as client:
|
||||||
|
for route, params in requests:
|
||||||
|
try:
|
||||||
|
response = client.get(route, params=params)
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
logger.debug("Audiobookshelf lookup failed for %s: %s", route, exc)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if response.status_code == 404:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
response.raise_for_status()
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
status = exc.response.status_code
|
||||||
|
if status in {401, 403}:
|
||||||
|
raise AudiobookshelfUploadError(
|
||||||
|
"Audiobookshelf authentication failed while checking for existing items."
|
||||||
|
) from exc
|
||||||
|
logger.debug("Audiobookshelf lookup error %s for %s", status, route)
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = response.json()
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
candidates = self._extract_candidate_items(payload)
|
||||||
|
for item in candidates:
|
||||||
|
item_title = self._normalize_item_title(item)
|
||||||
|
if not item_title or item_title != normalized_title:
|
||||||
|
continue
|
||||||
|
if target_folders:
|
||||||
|
item_folder = self._normalize_folder_id(item)
|
||||||
|
if item_folder and item_folder not in target_folders:
|
||||||
|
continue
|
||||||
|
matches.append(item)
|
||||||
|
if matches:
|
||||||
|
break
|
||||||
|
except AudiobookshelfUploadError:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
logger.debug(
|
||||||
|
"Unexpected error while checking Audiobookshelf for existing items",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
return matches
|
||||||
|
|
||||||
|
def delete_items(self, items: Iterable[Mapping[str, Any] | str]) -> None:
|
||||||
|
to_delete: List[str] = []
|
||||||
|
for entry in items:
|
||||||
|
if isinstance(entry, Mapping):
|
||||||
|
item_id = self._extract_item_id(entry)
|
||||||
|
else:
|
||||||
|
item_id = str(entry).strip()
|
||||||
|
if item_id:
|
||||||
|
to_delete.append(item_id)
|
||||||
|
|
||||||
|
if not to_delete:
|
||||||
|
return
|
||||||
|
|
||||||
|
with self._open_client() as client:
|
||||||
|
for item_id in to_delete:
|
||||||
|
self._delete_single_item(client, item_id)
|
||||||
|
|
||||||
|
def _candidate_search_requests(
|
||||||
|
self,
|
||||||
|
title: str,
|
||||||
|
folder_id: Optional[str],
|
||||||
|
) -> List[Tuple[str, Dict[str, Any]]]:
|
||||||
|
query = (title or "").strip()
|
||||||
|
if not query:
|
||||||
|
return []
|
||||||
|
|
||||||
|
library_id = self._config.library_id
|
||||||
|
folder_token = (folder_id or self._config.folder_id or "").strip()
|
||||||
|
|
||||||
|
requests: List[Tuple[str, Dict[str, Any]]] = []
|
||||||
|
seen_routes: set[str] = set()
|
||||||
|
|
||||||
|
def _append(route: str, params: Dict[str, Any]) -> None:
|
||||||
|
if route in seen_routes:
|
||||||
|
return
|
||||||
|
seen_routes.add(route)
|
||||||
|
requests.append((route, params))
|
||||||
|
|
||||||
|
if folder_token:
|
||||||
|
_append(
|
||||||
|
self._api_path(f"folders/{folder_token}/items"),
|
||||||
|
{"library": library_id, "search": query},
|
||||||
|
)
|
||||||
|
|
||||||
|
_append(self._api_path(f"libraries/{library_id}/items"), {"search": query})
|
||||||
|
_append(self._api_path("items"), {"library": library_id, "search": query})
|
||||||
|
_append(
|
||||||
|
self._api_path("search"),
|
||||||
|
{"query": query, "library": library_id, "media": "audiobook"},
|
||||||
|
)
|
||||||
|
|
||||||
|
return requests
|
||||||
|
|
||||||
|
def _delete_single_item(self, client: httpx.Client, item_id: str) -> None:
|
||||||
|
routes = [
|
||||||
|
self._api_path(f"items/{item_id}"),
|
||||||
|
self._api_path(f"libraries/{self._config.library_id}/items/{item_id}"),
|
||||||
|
]
|
||||||
|
|
||||||
|
for route in routes:
|
||||||
|
try:
|
||||||
|
response = client.delete(route)
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
logger.debug("Audiobookshelf delete failed for %s: %s", route, exc)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if response.status_code in (200, 202, 204):
|
||||||
|
return
|
||||||
|
if response.status_code == 404:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
response.raise_for_status()
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
raise AudiobookshelfUploadError(
|
||||||
|
f"Failed to delete Audiobookshelf item '{item_id}': {exc}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
logger.debug("Audiobookshelf item %s could not be confirmed deleted", item_id)
|
||||||
|
|
||||||
def resolve_folder(self) -> Tuple[str, str, str]:
|
def resolve_folder(self) -> Tuple[str, str, str]:
|
||||||
"""Return the resolved folder (id, name, library name)."""
|
"""Return the resolved folder (id, name, library name)."""
|
||||||
return self._ensure_folder()
|
return self._ensure_folder()
|
||||||
@@ -335,6 +489,87 @@ class AudiobookshelfClient:
|
|||||||
token = token.strip("/ ")
|
token = token.strip("/ ")
|
||||||
return token.lower()
|
return token.lower()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_title_value(value: Optional[str]) -> str:
|
||||||
|
if not isinstance(value, str):
|
||||||
|
return ""
|
||||||
|
normalized = re.sub(r"\s+", " ", value).strip()
|
||||||
|
return normalized.casefold() if normalized else ""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_item_title(item: Mapping[str, Any]) -> str:
|
||||||
|
if not isinstance(item, Mapping):
|
||||||
|
return ""
|
||||||
|
for key in ("title", "name", "label"):
|
||||||
|
candidate = item.get(key)
|
||||||
|
if isinstance(candidate, str) and candidate.strip():
|
||||||
|
return AudiobookshelfClient._normalize_title_value(candidate)
|
||||||
|
library_item = item.get("libraryItem")
|
||||||
|
if isinstance(library_item, Mapping):
|
||||||
|
return AudiobookshelfClient._normalize_item_title(library_item)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_folder_id(item: Mapping[str, Any]) -> Optional[str]:
|
||||||
|
if not isinstance(item, Mapping):
|
||||||
|
return None
|
||||||
|
for key in ("folderId", "libraryFolderId", "folder_id", "folder"):
|
||||||
|
value = item.get(key)
|
||||||
|
if isinstance(value, str) and value.strip():
|
||||||
|
return value.strip().lower()
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
return str(value).strip().lower()
|
||||||
|
library_item = item.get("libraryItem")
|
||||||
|
if isinstance(library_item, Mapping):
|
||||||
|
return AudiobookshelfClient._normalize_folder_id(library_item)
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_item_id(item: Mapping[str, Any]) -> Optional[str]:
|
||||||
|
if not isinstance(item, Mapping):
|
||||||
|
return None
|
||||||
|
for key in ("id", "libraryItemId", "itemId"):
|
||||||
|
value = item.get(key)
|
||||||
|
if isinstance(value, str) and value.strip():
|
||||||
|
return value.strip()
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
return str(value).strip()
|
||||||
|
library_item = item.get("libraryItem")
|
||||||
|
if isinstance(library_item, Mapping):
|
||||||
|
return AudiobookshelfClient._extract_item_id(library_item)
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_candidate_items(payload: Any) -> List[Mapping[str, Any]]:
|
||||||
|
items: List[Mapping[str, Any]] = []
|
||||||
|
seen_ids: set[str] = set()
|
||||||
|
visited: set[int] = set()
|
||||||
|
|
||||||
|
def _visit(obj: Any) -> None:
|
||||||
|
if isinstance(obj, Mapping):
|
||||||
|
obj_id = id(obj)
|
||||||
|
if obj_id in visited:
|
||||||
|
return
|
||||||
|
visited.add(obj_id)
|
||||||
|
|
||||||
|
title = AudiobookshelfClient._normalize_item_title(obj)
|
||||||
|
item_id = AudiobookshelfClient._extract_item_id(obj)
|
||||||
|
if title and item_id:
|
||||||
|
key = item_id.strip().lower()
|
||||||
|
if key not in seen_ids:
|
||||||
|
seen_ids.add(key)
|
||||||
|
items.append(obj)
|
||||||
|
|
||||||
|
for value in obj.values():
|
||||||
|
_visit(value)
|
||||||
|
|
||||||
|
elif isinstance(obj, list):
|
||||||
|
for entry in obj:
|
||||||
|
_visit(entry)
|
||||||
|
|
||||||
|
_visit(payload)
|
||||||
|
return items
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _extract_title(metadata: Mapping[str, Any], audio_path: Path) -> str:
|
def _extract_title(metadata: Mapping[str, Any], audio_path: Path) -> str:
|
||||||
title = metadata.get("title") if isinstance(metadata, Mapping) else None
|
title = metadata.get("title") if isinstance(metadata, Mapping) else None
|
||||||
|
|||||||
@@ -93,15 +93,22 @@ SUFFIX_CONTRACTION_BASES: Dict[str, Tuple[str, ...]] = {
|
|||||||
_NUMBER_WITH_GROUP_RE = re.compile(r"(?<![\w\d])(-?\d{1,3}(?:,\d{3})+)(?![\w\d])")
|
_NUMBER_WITH_GROUP_RE = re.compile(r"(?<![\w\d])(-?\d{1,3}(?:,\d{3})+)(?![\w\d])")
|
||||||
_NUMBER_RANGE_SEPARATORS = "-‐‑–—−"
|
_NUMBER_RANGE_SEPARATORS = "-‐‑–—−"
|
||||||
_NUMBER_RANGE_CLASS = re.escape(_NUMBER_RANGE_SEPARATORS)
|
_NUMBER_RANGE_CLASS = re.escape(_NUMBER_RANGE_SEPARATORS)
|
||||||
|
_NUMBER_CORE_PATTERN = r"-?(?:\d{1,3}(?:,\d{3})+|\d+)"
|
||||||
_WIDE_RANGE_SEPARATORS = {"–", "—"}
|
_WIDE_RANGE_SEPARATORS = {"–", "—"}
|
||||||
_NUMBER_RANGE_RE = re.compile(
|
_NUMBER_RANGE_RE = re.compile(
|
||||||
rf"(?<!\w)(?P<left>-?\d+)(?P<sep>\s*[{_NUMBER_RANGE_CLASS}]\s*)(?P<right>-?\d+)(?![\w{_NUMBER_RANGE_CLASS}/])"
|
rf"(?<!\w)(?P<left>{_NUMBER_CORE_PATTERN})(?P<sep>\s*[{_NUMBER_RANGE_CLASS}]\s*)(?P<right>{_NUMBER_CORE_PATTERN})(?![\w{_NUMBER_RANGE_CLASS}/])"
|
||||||
|
)
|
||||||
|
_NUMBER_SPACE_RANGE_RE = re.compile(
|
||||||
|
rf"(?<![\w{_NUMBER_RANGE_CLASS}/])(?P<left>{_NUMBER_CORE_PATTERN})(?P<gap>\s+)(?P<right>{_NUMBER_CORE_PATTERN})(?![\w{_NUMBER_RANGE_CLASS}/])"
|
||||||
)
|
)
|
||||||
_FRACTION_SLASHES = "/⁄"
|
_FRACTION_SLASHES = "/⁄"
|
||||||
_FRACTION_SLASH_CLASS = re.escape(_FRACTION_SLASHES)
|
_FRACTION_SLASH_CLASS = re.escape(_FRACTION_SLASHES)
|
||||||
_FRACTION_RE = re.compile(
|
_FRACTION_RE = re.compile(
|
||||||
rf"(?<!\w)(?P<numerator>-?\d+)\s*[{_FRACTION_SLASH_CLASS}]\s*(?P<denominator>-?\d+)(?![\w{_FRACTION_SLASH_CLASS}])"
|
rf"(?<!\w)(?P<numerator>-?\d+)\s*[{_FRACTION_SLASH_CLASS}]\s*(?P<denominator>-?\d+)(?![\w{_FRACTION_SLASH_CLASS}])"
|
||||||
)
|
)
|
||||||
|
_PLAIN_NUMBER_RE = re.compile(
|
||||||
|
rf"(?<![\w{_NUMBER_RANGE_CLASS}/])(?P<number>{_NUMBER_CORE_PATTERN})(?![\w{_NUMBER_RANGE_CLASS}/])"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _int_to_words(value: int, language: str) -> Optional[str]:
|
def _int_to_words(value: int, language: str) -> Optional[str]:
|
||||||
@@ -195,21 +202,25 @@ def _format_fraction_words(numerator: int, denominator: int, language: str) -> O
|
|||||||
def _replace_number_range(match: re.Match[str], language: str) -> str:
|
def _replace_number_range(match: re.Match[str], language: str) -> str:
|
||||||
left_raw = match.group("left")
|
left_raw = match.group("left")
|
||||||
right_raw = match.group("right")
|
right_raw = match.group("right")
|
||||||
separator_text = match.group("sep") or ""
|
left = _coerce_int_token(left_raw)
|
||||||
separator_char = next((ch for ch in separator_text if ch in _NUMBER_RANGE_SEPARATORS), "-")
|
right = _coerce_int_token(right_raw)
|
||||||
has_whitespace = any(ch.isspace() for ch in separator_text)
|
if left is None or right is None:
|
||||||
|
|
||||||
left_digits = len(left_raw.lstrip("-"))
|
|
||||||
right_digits = len(right_raw.lstrip("-"))
|
|
||||||
|
|
||||||
if (left_digits >= 4 or right_digits >= 4) and separator_char not in _WIDE_RANGE_SEPARATORS and not has_whitespace:
|
|
||||||
return match.group(0)
|
return match.group(0)
|
||||||
if {left_digits, right_digits} == {3, 4} and separator_char not in _WIDE_RANGE_SEPARATORS and not has_whitespace:
|
|
||||||
|
left_words = _int_to_words(left, language)
|
||||||
|
right_words = _int_to_words(right, language)
|
||||||
|
if not left_words or not right_words:
|
||||||
return match.group(0)
|
return match.group(0)
|
||||||
try:
|
|
||||||
left = int(left_raw)
|
return f"{left_words} to {right_words}"
|
||||||
right = int(right_raw)
|
|
||||||
except ValueError:
|
|
||||||
|
def _replace_space_separated_range(match: re.Match[str], language: str) -> str:
|
||||||
|
left_raw = match.group("left")
|
||||||
|
right_raw = match.group("right")
|
||||||
|
left = _coerce_int_token(left_raw)
|
||||||
|
right = _coerce_int_token(right_raw)
|
||||||
|
if left is None or right is None:
|
||||||
return match.group(0)
|
return match.group(0)
|
||||||
|
|
||||||
left_words = _int_to_words(left, language)
|
left_words = _int_to_words(left, language)
|
||||||
@@ -233,6 +244,18 @@ def _replace_fraction(match: re.Match[str], language: str) -> str:
|
|||||||
if not spoken:
|
if not spoken:
|
||||||
return match.group(0)
|
return match.group(0)
|
||||||
return spoken
|
return spoken
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_int_token(token: str) -> Optional[int]:
|
||||||
|
if token is None:
|
||||||
|
return None
|
||||||
|
cleaned = token.replace(",", "").strip()
|
||||||
|
if not cleaned or cleaned in {"-", "+"}:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(cleaned)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
AMBIGUOUS_D_BASES = {"i","you","he","she","we","they"}
|
AMBIGUOUS_D_BASES = {"i","you","he","she","we","they"}
|
||||||
AMBIGUOUS_S_BASES = {
|
AMBIGUOUS_S_BASES = {
|
||||||
"it",
|
"it",
|
||||||
@@ -862,33 +885,54 @@ def _normalize_grouped_numbers(text: str, cfg: ApostropheConfig) -> str:
|
|||||||
|
|
||||||
language = (cfg.number_lang or "en").strip() or "en"
|
language = (cfg.number_lang or "en").strip() or "en"
|
||||||
|
|
||||||
def _replace(match: re.Match[str]) -> str:
|
def _replace_grouped(match: re.Match[str]) -> str:
|
||||||
token = match.group(1)
|
token = match.group(1)
|
||||||
cleaned = token.replace(",", "")
|
value = _coerce_int_token(token)
|
||||||
if not cleaned:
|
if value is None:
|
||||||
return token
|
cleaned = token.replace(",", "")
|
||||||
negative = cleaned.startswith("-")
|
|
||||||
cleaned_digits = cleaned[1:] if negative else cleaned
|
|
||||||
|
|
||||||
if not cleaned_digits.isdigit():
|
|
||||||
return cleaned_digits if not negative else f"-{cleaned_digits}"
|
|
||||||
|
|
||||||
if num2words is None:
|
|
||||||
return ("-" if negative else "") + cleaned_digits
|
|
||||||
|
|
||||||
try:
|
|
||||||
value = int(cleaned)
|
|
||||||
except ValueError:
|
|
||||||
return cleaned
|
return cleaned
|
||||||
|
if num2words is None:
|
||||||
words = _int_to_words(value, language)
|
|
||||||
if not words:
|
|
||||||
return str(value)
|
return str(value)
|
||||||
return words
|
words = _int_to_words(value, language)
|
||||||
|
return words or str(value)
|
||||||
|
|
||||||
normalized = _NUMBER_WITH_GROUP_RE.sub(_replace, text)
|
def _replace_plain(match: re.Match[str]) -> str:
|
||||||
|
token = match.group("number")
|
||||||
|
if "," in token:
|
||||||
|
return token.replace(",", "")
|
||||||
|
|
||||||
|
start, end = match.span()
|
||||||
|
source = match.string
|
||||||
|
before = source[start - 1] if start > 0 else ""
|
||||||
|
after = source[end] if end < len(source) else ""
|
||||||
|
|
||||||
|
if before == "/" or after == "/":
|
||||||
|
return token
|
||||||
|
|
||||||
|
if after == ".":
|
||||||
|
next_char = source[end + 1] if end + 1 < len(source) else ""
|
||||||
|
if next_char.isdigit():
|
||||||
|
return token
|
||||||
|
|
||||||
|
if before == ".":
|
||||||
|
prev_char = source[start - 2] if start >= 2 else ""
|
||||||
|
if prev_char.isdigit() or start == 1:
|
||||||
|
return token
|
||||||
|
|
||||||
|
value = _coerce_int_token(token)
|
||||||
|
if value is None:
|
||||||
|
return token
|
||||||
|
if num2words is None:
|
||||||
|
return str(value)
|
||||||
|
words = _int_to_words(value, language)
|
||||||
|
return words or str(value)
|
||||||
|
|
||||||
|
normalized = text
|
||||||
normalized = _NUMBER_RANGE_RE.sub(lambda m: _replace_number_range(m, language), normalized)
|
normalized = _NUMBER_RANGE_RE.sub(lambda m: _replace_number_range(m, language), normalized)
|
||||||
|
normalized = _NUMBER_SPACE_RANGE_RE.sub(lambda m: _replace_space_separated_range(m, language), normalized)
|
||||||
normalized = _FRACTION_RE.sub(lambda m: _replace_fraction(m, language), normalized)
|
normalized = _FRACTION_RE.sub(lambda m: _replace_fraction(m, language), normalized)
|
||||||
|
normalized = _NUMBER_WITH_GROUP_RE.sub(_replace_grouped, normalized)
|
||||||
|
normalized = _PLAIN_NUMBER_RE.sub(_replace_plain, normalized)
|
||||||
return normalized
|
return normalized
|
||||||
|
|
||||||
# ---------- Optional phoneme hint post-processing ----------
|
# ---------- Optional phoneme hint post-processing ----------
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import traceback
|
import traceback
|
||||||
|
from datetime import datetime
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from contextlib import ExitStack
|
from contextlib import ExitStack
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
@@ -107,6 +108,7 @@ _ACRONYM_ALLOWLIST = {
|
|||||||
}
|
}
|
||||||
_ROMAN_NUMERAL_CHARS = frozenset("IVXLCDM")
|
_ROMAN_NUMERAL_CHARS = frozenset("IVXLCDM")
|
||||||
_CAPS_WORD_RE = re.compile(r"[A-Z][A-Z0-9'\u2019-]*")
|
_CAPS_WORD_RE = re.compile(r"[A-Z][A-Z0-9'\u2019-]*")
|
||||||
|
_OUTPUT_SANITIZE_RE = re.compile(r"[^\w\-_.]+")
|
||||||
|
|
||||||
|
|
||||||
def _simplify_heading_text(text: str) -> str:
|
def _simplify_heading_text(text: str) -> str:
|
||||||
@@ -1844,7 +1846,7 @@ def run_conversion_job(job: Job) -> None:
|
|||||||
|
|
||||||
if audio_asset:
|
if audio_asset:
|
||||||
try:
|
try:
|
||||||
epub_root = project_root if job.save_as_project else base_output_dir
|
epub_root = project_root
|
||||||
epub_output_path = _build_output_path(epub_root, job.original_filename, "epub")
|
epub_output_path = _build_output_path(epub_root, job.original_filename, "epub")
|
||||||
job.add_log("Generating EPUB 3 package with synchronized narration…")
|
job.add_log("Generating EPUB 3 package with synchronized narration…")
|
||||||
epub_path = build_epub3_package(
|
epub_path = build_epub3_package(
|
||||||
@@ -1989,21 +1991,19 @@ def _prepare_output_dir(job: Job) -> Path:
|
|||||||
|
|
||||||
|
|
||||||
def _build_output_path(directory: Path, original_name: str, extension: str) -> Path:
|
def _build_output_path(directory: Path, original_name: str, extension: str) -> Path:
|
||||||
base_name = Path(original_name).stem
|
sanitized = _sanitize_output_stem(original_name)
|
||||||
sanitized = re.sub(r"[^\w\-_.]+", "_", base_name).strip("_") or "output"
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
candidate = directory / f"{sanitized}.{extension}"
|
return directory / f"{sanitized}.{extension}"
|
||||||
counter = 1
|
|
||||||
while candidate.exists():
|
|
||||||
candidate = directory / f"{sanitized}_{counter}.{extension}"
|
|
||||||
counter += 1
|
|
||||||
return candidate
|
|
||||||
|
|
||||||
|
|
||||||
def _prepare_project_layout(job: Job, base_dir: Path) -> tuple[Path, Path, Path, Optional[Path]]:
|
def _prepare_project_layout(job: Job, base_dir: Path) -> tuple[Path, Path, Path, Optional[Path]]:
|
||||||
base_dir.mkdir(parents=True, exist_ok=True)
|
base_dir.mkdir(parents=True, exist_ok=True)
|
||||||
stem = Path(job.original_filename).stem
|
sanitized = _sanitize_output_stem(job.original_filename)
|
||||||
|
folder_name = f"{_output_timestamp_token()}_{sanitized}"
|
||||||
|
project_root = base_dir / folder_name
|
||||||
|
project_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
if job.save_as_project:
|
if job.save_as_project:
|
||||||
project_root = _ensure_unique_directory(base_dir, f"{stem}_project")
|
|
||||||
audio_dir = project_root / "audio"
|
audio_dir = project_root / "audio"
|
||||||
subtitle_dir = project_root / "subtitles"
|
subtitle_dir = project_root / "subtitles"
|
||||||
metadata_dir = project_root / "metadata"
|
metadata_dir = project_root / "metadata"
|
||||||
@@ -2011,17 +2011,7 @@ def _prepare_project_layout(job: Job, base_dir: Path) -> tuple[Path, Path, Path,
|
|||||||
directory.mkdir(parents=True, exist_ok=True)
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
return project_root, audio_dir, subtitle_dir, metadata_dir
|
return project_root, audio_dir, subtitle_dir, metadata_dir
|
||||||
|
|
||||||
return base_dir, base_dir, base_dir, None
|
return project_root, project_root, project_root, None
|
||||||
|
|
||||||
|
|
||||||
def _ensure_unique_directory(parent: Path, name: str) -> Path:
|
|
||||||
candidate = parent / name
|
|
||||||
counter = 1
|
|
||||||
while candidate.exists():
|
|
||||||
candidate = parent / f"{name}_{counter}"
|
|
||||||
counter += 1
|
|
||||||
candidate.mkdir(parents=True, exist_ok=True)
|
|
||||||
return candidate
|
|
||||||
|
|
||||||
|
|
||||||
def _apply_newline_policy(chapters: List[ExtractedChapter], replace_single_newlines: bool) -> None:
|
def _apply_newline_policy(chapters: List[ExtractedChapter], replace_single_newlines: bool) -> None:
|
||||||
@@ -2039,6 +2029,16 @@ def _slugify(title: str, index: int) -> str:
|
|||||||
return sanitized[:80]
|
return sanitized[:80]
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_output_stem(name: str) -> str:
|
||||||
|
base = Path(name or "").stem
|
||||||
|
sanitized = _OUTPUT_SANITIZE_RE.sub("_", base).strip("_")
|
||||||
|
return sanitized or "output"
|
||||||
|
|
||||||
|
|
||||||
|
def _output_timestamp_token() -> str:
|
||||||
|
return datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||||
|
|
||||||
|
|
||||||
def _open_audio_sink(
|
def _open_audio_sink(
|
||||||
path: Path,
|
path: Path,
|
||||||
job: Job,
|
job: Job,
|
||||||
|
|||||||
+47
-1
@@ -5064,10 +5064,56 @@ def send_job_to_audiobookshelf(job_id: str) -> ResponseReturnValue:
|
|||||||
subtitles = _existing_paths(job.result.subtitle_paths) if config.send_subtitles else None
|
subtitles = _existing_paths(job.result.subtitle_paths) if config.send_subtitles else None
|
||||||
chapters = _load_audiobookshelf_chapters(job) if config.send_chapters else None
|
chapters = _load_audiobookshelf_chapters(job) if config.send_chapters else None
|
||||||
metadata = _build_audiobookshelf_metadata(job)
|
metadata = _build_audiobookshelf_metadata(job)
|
||||||
|
display_title = metadata.get("title") or audio_path.stem
|
||||||
|
overwrite_requested = request.form.get("overwrite") == "true" or request.args.get("overwrite") == "true"
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = AudiobookshelfClient(config)
|
||||||
|
except ValueError as exc: # pragma: no cover - defensive guard
|
||||||
|
job.add_log(f"Audiobookshelf configuration error: {exc}", level="error")
|
||||||
|
service._persist_state()
|
||||||
|
return _panel_response()
|
||||||
|
|
||||||
|
try:
|
||||||
|
existing_items = client.find_existing_items(display_title, folder_id=config.folder_id)
|
||||||
|
except AudiobookshelfUploadError as exc:
|
||||||
|
job.add_log(f"Audiobookshelf lookup failed: {exc}", level="error")
|
||||||
|
service._persist_state()
|
||||||
|
return _panel_response()
|
||||||
|
|
||||||
|
if existing_items and not overwrite_requested:
|
||||||
|
job.add_log(
|
||||||
|
f"Audiobookshelf already contains '{display_title}'. Awaiting overwrite confirmation.",
|
||||||
|
level="warning",
|
||||||
|
)
|
||||||
|
service._persist_state()
|
||||||
|
if request.headers.get("HX-Request"):
|
||||||
|
detail = {
|
||||||
|
"jobId": job.id,
|
||||||
|
"title": display_title,
|
||||||
|
"url": url_for("web.send_job_to_audiobookshelf", job_id=job.id),
|
||||||
|
"target": request.headers.get("HX-Target") or "#jobs-panel",
|
||||||
|
"message": f'Audiobookshelf already contains "{display_title}". Overwrite?',
|
||||||
|
}
|
||||||
|
headers = {"HX-Trigger": json.dumps({"audiobookshelf-overwrite-prompt": detail})}
|
||||||
|
return Response("", status=204, headers=headers)
|
||||||
|
return _panel_response()
|
||||||
|
|
||||||
|
if existing_items and overwrite_requested:
|
||||||
|
try:
|
||||||
|
client.delete_items(existing_items)
|
||||||
|
except AudiobookshelfUploadError as exc:
|
||||||
|
job.add_log(f"Audiobookshelf overwrite aborted: {exc}", level="error")
|
||||||
|
service._persist_state()
|
||||||
|
return _panel_response()
|
||||||
|
else:
|
||||||
|
job.add_log(
|
||||||
|
f"Removed {len(existing_items)} existing Audiobookshelf item(s) prior to overwrite.",
|
||||||
|
level="info",
|
||||||
|
)
|
||||||
|
|
||||||
job.add_log("Audiobookshelf upload triggered manually.", level="info")
|
job.add_log("Audiobookshelf upload triggered manually.", level="info")
|
||||||
try:
|
try:
|
||||||
client = AudiobookshelfClient(config)
|
|
||||||
client.upload_audiobook(
|
client.upload_audiobook(
|
||||||
audio_path,
|
audio_path,
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
|
|||||||
@@ -999,6 +999,21 @@ class ConversionService:
|
|||||||
metadata = _build_audiobookshelf_metadata(job)
|
metadata = _build_audiobookshelf_metadata(job)
|
||||||
|
|
||||||
client = AudiobookshelfClient(config)
|
client = AudiobookshelfClient(config)
|
||||||
|
|
||||||
|
display_title = metadata.get("title") or audio_path.stem
|
||||||
|
try:
|
||||||
|
existing_items = client.find_existing_items(display_title, folder_id=config.folder_id)
|
||||||
|
except AudiobookshelfUploadError as exc:
|
||||||
|
job.add_log(f"Audiobookshelf lookup failed: {exc}", level="error")
|
||||||
|
return
|
||||||
|
|
||||||
|
if existing_items:
|
||||||
|
job.add_log(
|
||||||
|
f"Audiobookshelf upload skipped: '{display_title}' already exists in the configured folder.",
|
||||||
|
level="warning",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
client.upload_audiobook(
|
client.upload_audiobook(
|
||||||
audio_path,
|
audio_path,
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
|
|||||||
@@ -1,7 +1,41 @@
|
|||||||
import { initReaderUI } from "./reader.js";
|
import { initReaderUI } from "./reader.js";
|
||||||
|
|
||||||
|
const queueState = (window.AbogenQueueState = window.AbogenQueueState || {
|
||||||
|
boundOverwritePrompt: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleOverwritePrompt = (event) => {
|
||||||
|
const detail = event?.detail || {};
|
||||||
|
const title = detail.title || "this item";
|
||||||
|
const message = detail.message || `Audiobookshelf already has "${title}". Overwrite?`;
|
||||||
|
if (!window.confirm(message)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = detail.url;
|
||||||
|
if (!url || typeof htmx === "undefined") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = detail.target || "#jobs-panel";
|
||||||
|
const values = { overwrite: "true" };
|
||||||
|
if (detail.values && typeof detail.values === "object") {
|
||||||
|
Object.assign(values, detail.values);
|
||||||
|
}
|
||||||
|
|
||||||
|
htmx.ajax("POST", url, {
|
||||||
|
target,
|
||||||
|
swap: "innerHTML",
|
||||||
|
values,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const initQueuePage = () => {
|
const initQueuePage = () => {
|
||||||
initReaderUI();
|
initReaderUI();
|
||||||
|
if (!queueState.boundOverwritePrompt) {
|
||||||
|
queueState.boundOverwritePrompt = true;
|
||||||
|
document.addEventListener("audiobookshelf-overwrite-prompt", handleOverwritePrompt);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (document.readyState === "loading") {
|
if (document.readyState === "loading") {
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from abogen.web.conversion_runner import _build_output_path, _prepare_project_layout
|
||||||
|
from abogen.web.service import Job
|
||||||
|
|
||||||
|
|
||||||
|
def _sample_job(tmp_path: Path) -> Job:
|
||||||
|
source = tmp_path / "sample.txt"
|
||||||
|
source.write_text("example", encoding="utf-8")
|
||||||
|
return Job(
|
||||||
|
id="job-1",
|
||||||
|
original_filename="Sample Title.txt",
|
||||||
|
stored_path=source,
|
||||||
|
language="en",
|
||||||
|
voice="af_alloy",
|
||||||
|
speed=1.0,
|
||||||
|
use_gpu=False,
|
||||||
|
subtitle_mode="Sentence",
|
||||||
|
output_format="mp3",
|
||||||
|
save_mode="Use default save location",
|
||||||
|
output_folder=tmp_path,
|
||||||
|
replace_single_newlines=False,
|
||||||
|
subtitle_format="srt",
|
||||||
|
created_at=time.time(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_prepare_project_layout_uses_timestamped_folder(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||||
|
job = _sample_job(tmp_path)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"abogen.web.conversion_runner._output_timestamp_token",
|
||||||
|
lambda: "20250101-120000",
|
||||||
|
)
|
||||||
|
|
||||||
|
project_root, audio_dir, subtitle_dir, metadata_dir = _prepare_project_layout(job, tmp_path)
|
||||||
|
|
||||||
|
assert project_root.name.startswith("20250101-120000_sample_title"), project_root.name
|
||||||
|
assert audio_dir == project_root
|
||||||
|
assert subtitle_dir == project_root
|
||||||
|
assert metadata_dir is None
|
||||||
|
|
||||||
|
output_path = _build_output_path(audio_dir, job.original_filename, "mp3")
|
||||||
|
assert output_path == project_root / "Sample Title.mp3"
|
||||||
|
|
||||||
|
|
||||||
|
def test_prepare_project_layout_creates_project_subdirs(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||||
|
job = _sample_job(tmp_path)
|
||||||
|
job.save_as_project = True
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"abogen.web.conversion_runner._output_timestamp_token",
|
||||||
|
lambda: "20250101-120500",
|
||||||
|
)
|
||||||
|
|
||||||
|
project_root, audio_dir, subtitle_dir, metadata_dir = _prepare_project_layout(job, tmp_path)
|
||||||
|
|
||||||
|
assert audio_dir == project_root / "audio"
|
||||||
|
assert subtitle_dir == project_root / "subtitles"
|
||||||
|
assert metadata_dir == project_root / "metadata"
|
||||||
|
assert audio_dir.is_dir()
|
||||||
|
assert subtitle_dir.is_dir()
|
||||||
|
assert metadata_dir is not None and metadata_dir.is_dir()
|
||||||
|
|
||||||
|
output_path = _build_output_path(audio_dir, job.original_filename, "wav")
|
||||||
|
assert output_path == audio_dir / "Sample Title.wav"
|
||||||
@@ -86,6 +86,16 @@ def test_simple_fractions_are_spoken() -> None:
|
|||||||
assert "one half" in normalized.lower()
|
assert "one half" in normalized.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_plain_numbers_are_spelled_out() -> None:
|
||||||
|
normalized = _normalize_for_pipeline("He rolled a 42.")
|
||||||
|
assert "forty-two" in normalized.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_space_separated_numbers_become_ranges() -> None:
|
||||||
|
normalized = _normalize_for_pipeline("Read pages 12 14 tonight.")
|
||||||
|
assert "pages twelve to fourteen" in normalized.lower()
|
||||||
|
|
||||||
|
|
||||||
def test_contractions_can_be_kept_when_override_disabled() -> None:
|
def test_contractions_can_be_kept_when_override_disabled() -> None:
|
||||||
normalized = _normalize_for_pipeline(
|
normalized = _normalize_for_pipeline(
|
||||||
"It's a good day.",
|
"It's a good day.",
|
||||||
|
|||||||
Reference in New Issue
Block a user