aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/backends/common.py46
-rw-r--r--app/backends/servers.py189
-rw-r--r--app/converter/audio.py13
-rw-r--r--app/converter/chunking.py97
-rw-r--r--app/converter/clients/base.py5
-rw-r--r--app/converter/converter.py94
-rw-r--r--app/converter/extractors.py96
-rw-r--r--app/tests/test_audio.py30
-rw-r--r--app/tests/test_backends_audiocpp.py2
-rw-r--r--app/tests/test_backends_servers.py37
-rw-r--r--app/tests/test_chunking.py41
-rw-r--r--app/tests/test_cleaning.py61
-rw-r--r--app/tests/test_converter.py19
-rw-r--r--app/tests/test_extractors.py60
-rw-r--r--app/tests/test_hub.py46
-rwxr-xr-xaudiobook.py65
16 files changed, 731 insertions, 170 deletions
diff --git a/app/backends/common.py b/app/backends/common.py
index 7974b58..20de432 100644
--- a/app/backends/common.py
+++ b/app/backends/common.py
@@ -9,6 +9,7 @@ TUI) so it can be reused without pulling curses into a non-interactive
run.
"""
+import ast
import os
import re
import shutil
@@ -306,25 +307,50 @@ def update_config_value(key: str, value,
"""Set ``KEY`` to VALUE in app/converter/config.py and in memory.
Only the value of the named assignment changes: indentation and any
- trailing comment are preserved. Strings render double-quoted; other
- literals (ints, booleans) render bare. After a successful write (or
- when the file already holds VALUE) the new value is mirrored onto the
- imported ``converter.config`` module, so a wizard's change takes
- effect immediately instead of only after the next process start.
- Returns True when the file now holds VALUE, False when it could not
- be read or written (or KEY has no line in it).
+ trailing comment are preserved. Strings render as proper Python
+ literals via ``repr`` (quoting with bare double quotes would instead
+ produce invalid syntax — or silently change the value — whenever the
+ string itself contains a quote or a backslash, corrupting
+ config.py); other literals (ints, booleans) render bare. After a
+ successful write (or when the file already holds VALUE) the new
+ value is mirrored onto the imported ``converter.config`` module, so
+ a wizard's change takes effect immediately instead of only after
+ the next process start. Returns True when the file now holds VALUE,
+ False when it could not be read or written (or KEY has no line in
+ it, or the edit would not parse).
"""
path = Path(config_path) if config_path is not None else CONFIG_PATH
- rendered = f'"{value}"' if isinstance(value, str) else str(value)
+ rendered = repr(value) if isinstance(value, str) else str(value)
try:
text = path.read_text(encoding="utf-8")
match = re.search(
- rf'(?m)^(\s*{re.escape(key)}\s*=\s*)("[^"]*"|\S+)(\s*(?:#.*)?)$',
+ rf'(?m)^(\s*{re.escape(key)}\s*=\s*)'
+ # Any valid Python string literal, single- or double-quoted
+ # (both spellings occur once repr() has written a value),
+ # else a bare literal token.
+ r'("[^"\\]*(?:\\.[^"\\]*)*"'
+ r"|'[^'\\]*(?:\\.[^'\\]*)*'"
+ r'|\S+)'
+ r'(\s*(?:#.*)?)$',
text)
if match is None:
return False
- if match.group(2) != rendered:
+ try:
+ # Semantic equality first: a file still holding the value in
+ # the old quoting style must not be rewritten (a no-op save
+ # stays a no-op), and the matched token may be any literal.
+ same = (match.group(2) == rendered
+ or ast.literal_eval(match.group(2)) == value)
+ except (ValueError, SyntaxError):
+ same = match.group(2) == rendered
+ if not same:
text = text[:match.start(2)] + rendered + text[match.end(2):]
+ try:
+ # Never write a file that fails to import: a broken
+ # config.py breaks every later process start.
+ compile(text, str(path), "exec")
+ except (SyntaxError, ValueError):
+ return False
path.write_text(text, encoding="utf-8")
except OSError:
return False
diff --git a/app/backends/servers.py b/app/backends/servers.py
index fd28866..781ecb5 100644
--- a/app/backends/servers.py
+++ b/app/backends/servers.py
@@ -22,6 +22,7 @@ boot screen, which renders the same events. Pid/log files live under
``app/logs/`` which is already gitignored.
"""
+import contextlib
import os
import re
import signal
@@ -142,6 +143,43 @@ def _pid_path(name: str) -> Path:
return LOG_DIR / f"{name}-server.pid"
+@contextlib.contextmanager
+def _start_lock(name: str):
+ """Serialize a ``start``'s stale-pid cleanup, spawn and pid
+ publication for NAME.
+
+ Two starters racing through ``start`` could otherwise unlink each
+ other's just-created, still-empty pid-file reservation (created
+ before the spawned pid is written) and both end up spawning a
+ server. The lock (an advisory fcntl/msvcrt lock, blocking so the
+ loser waits only as long as the winner's spawn takes) is held from
+ the liveness check until the pid file carries the spawned pid; the
+ boot wait happens outside it. The lock dies with its holder, so a
+ crashed starter never wedges later starts; platforms without file
+ locking degrade to the old unlocked behavior.
+ """
+ LOG_DIR.mkdir(parents=True, exist_ok=True)
+ handle = open(LOG_DIR / f"{name}-server.start.lock", "w")
+ try:
+ try:
+ import fcntl
+ try:
+ fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
+ except OSError:
+ pass # degrade rather than fail the start
+ except ImportError:
+ try:
+ import msvcrt
+ handle.write("0")
+ handle.flush()
+ msvcrt.locking(handle.fileno(), msvcrt.LK_LOCK, 1)
+ except (ImportError, OSError):
+ pass
+ yield
+ finally:
+ handle.close() # releases the lock
+
+
def _read_log_tail(name: str, lines: int = 20) -> List[str]:
"""Return the last LINES of the server's log (best-effort)."""
try:
@@ -338,10 +376,11 @@ def _kill_pid(pid: int) -> bool:
except PermissionError:
return False
for _ in range(int(STOP_GRACE_SECONDS * 10)):
- # Reap first so an exited (zombie) child ends the wait immediately
- # instead of keeping the killpg(0) probe "alive" until SIGKILL.
- if _reap_exited(pid):
- return True
+ # Reap each round so an exited (zombie) child stops keeping the
+ # killpg(0) probe "alive" until SIGKILL. Reaping the launcher is
+ # NOT proof the group is gone — workers can outlive it — so the
+ # group probe below, not the reap, decides when the wait ends.
+ _reap_exited(pid)
try:
os.killpg(pgid, 0)
except ProcessLookupError:
@@ -429,82 +468,86 @@ def start(spec, progress: ProgressCallback = None,
# Refuse to double-start: a live pid file means a previous start is
# still booting (or its process is wedged). Spawning a second server
# on the same port would orphan the first with no pid record left.
- if alive(spec.name):
- report({"kind": "error",
- "message": f"a {spec.name} server (pid "
- f"{pid_for(spec.name)}) is already starting or "
- "running; stop it first"})
- return False
- # Refuse to spawn onto a port a foreign process already holds: TCP-up
- # but probe-down means the listener is not a usable instance of this
- # server. A fresh spawn would then either die on the bind or (launchers
- # that fall back silently, like sglang-omni) move to a random port and
- # leave every client polling the taken one — the boot watchdog below
- # catches that late, so name the conflict here.
- if listening:
- report({"kind": "error", "message": _port_taken_message(spec)})
- return False
- pid_file = _pid_path(spec.name)
- if pid_file.exists():
+ # The start lock keeps the liveness check, stale cleanup, spawn and
+ # pid publication serialized against a concurrent starter (see
+ # _start_lock); the boot wait below runs outside it.
+ with _start_lock(spec.name):
+ if alive(spec.name):
+ report({"kind": "error",
+ "message": f"a {spec.name} server (pid "
+ f"{pid_for(spec.name)}) is already starting or "
+ "running; stop it first"})
+ return False
+ # Refuse to spawn onto a port a foreign process already holds: TCP-up
+ # but probe-down means the listener is not a usable instance of this
+ # server. A fresh spawn would then either die on the bind or (launchers
+ # that fall back silently, like sglang-omni) move to a random port and
+ # leave every client polling the taken one — the boot watchdog below
+ # catches that late, so name the conflict here.
+ if listening:
+ report({"kind": "error", "message": _port_taken_message(spec)})
+ return False
+ pid_file = _pid_path(spec.name)
+ if pid_file.exists():
+ try:
+ pid_file.unlink()
+ except OSError:
+ pass
+ # Reserve the slot atomically (O_EXCL): two concurrent starters can
+ # both pass the liveness check above, but only one wins the create —
+ # the loser refuses instead of spawning a duplicate server on the port.
try:
- pid_file.unlink()
+ pid_handle = pid_file.open("x")
+ except FileExistsError:
+ report({"kind": "error",
+ "message": f"a {spec.name} server is already starting "
+ "(its pid file appeared while this start was "
+ "running); stop it first"})
+ return False
except OSError:
- pass
- # Reserve the slot atomically (O_EXCL): two concurrent starters can
- # both pass the liveness check above, but only one wins the create —
- # the loser refuses instead of spawning a duplicate server on the port.
- try:
- pid_handle = pid_file.open("x")
- except FileExistsError:
- report({"kind": "error",
- "message": f"a {spec.name} server is already starting "
- "(its pid file appeared while this start was "
- "running); stop it first"})
- return False
- except OSError:
- pid_handle = None
-
- cwd = getattr(spec, "cwd", None)
- # Append so an earlier boot's output survives (crash-loop debugging);
- # the child inherits the handle and the parent's copy is closed right
- # after the spawn, so nothing leaks here.
- log_handle = _log_path(spec.name).open("a", encoding="utf-8")
- popen_kwargs = {"stdout": log_handle, "stderr": subprocess.STDOUT}
- if cwd is not None:
- popen_kwargs["cwd"] = str(cwd)
- if sys.platform == "win32":
- popen_kwargs["creationflags"] = \
- subprocess.CREATE_NEW_PROCESS_GROUP # type: ignore[attr-defined]
- else:
- popen_kwargs["start_new_session"] = True
- try:
- proc = subprocess.Popen(argv, **popen_kwargs)
- except OSError as exc:
- report({"kind": "error",
- "message": f"could not start server: {exc}"})
+ pid_handle = None
+
+ cwd = getattr(spec, "cwd", None)
+ # Append so an earlier boot's output survives (crash-loop debugging);
+ # the child inherits the handle and the parent's copy is closed right
+ # after the spawn, so nothing leaks here.
+ log_handle = _log_path(spec.name).open("a", encoding="utf-8")
+ popen_kwargs = {"stdout": log_handle, "stderr": subprocess.STDOUT}
+ if cwd is not None:
+ popen_kwargs["cwd"] = str(cwd)
+ if sys.platform == "win32":
+ popen_kwargs["creationflags"] = \
+ subprocess.CREATE_NEW_PROCESS_GROUP # type: ignore[attr-defined]
+ else:
+ popen_kwargs["start_new_session"] = True
+ try:
+ proc = subprocess.Popen(argv, **popen_kwargs)
+ except OSError as exc:
+ report({"kind": "error",
+ "message": f"could not start server: {exc}"})
+ log_handle.close()
+ if pid_handle is not None:
+ pid_handle.close()
+ try:
+ pid_file.unlink()
+ except OSError:
+ pass
+ return False
+ log_handle.write(f"\n=== boot {datetime.now():%Y-%m-%d %H:%M:%S} "
+ f"(pid {proc.pid}) ===\n")
+ log_handle.flush()
log_handle.close()
+
+ # "pid token": the process's start time where the platform provides
+ # one, so a recycled pid is never mistaken for our server (see
+ # _pid_owned). Empty token = bare-pid probing.
+ token = _process_start_token(proc.pid) or ""
if pid_handle is not None:
- pid_handle.close()
try:
- pid_file.unlink()
+ pid_handle.write(f"{proc.pid} {token}\n".strip() + "\n")
+ pid_handle.close()
except OSError:
pass
- return False
- log_handle.write(f"\n=== boot {datetime.now():%Y-%m-%d %H:%M:%S} "
- f"(pid {proc.pid}) ===\n")
- log_handle.flush()
- log_handle.close()
-
- # "pid token": the process's start time where the platform provides
- # one, so a recycled pid is never mistaken for our server (see
- # _pid_owned). Empty token = bare-pid probing.
- token = _process_start_token(proc.pid) or ""
- if pid_handle is not None:
- try:
- pid_handle.write(f"{proc.pid} {token}\n".strip() + "\n")
- pid_handle.close()
- except OSError:
- pass
report({"kind": "starting", "name": spec.name,
"argv": " ".join(str(a) for a in argv),
"cwd": str(cwd) if cwd is not None else None,
diff --git a/app/converter/audio.py b/app/converter/audio.py
index 9df7aff..9536788 100644
--- a/app/converter/audio.py
+++ b/app/converter/audio.py
@@ -6,6 +6,7 @@ threads them through, so there is no module-global path to mutate.
"""
import logging
+import math
import re
import shutil
import subprocess
@@ -29,7 +30,12 @@ def atempo_filters(speed: float) -> str:
``atempo`` accepts 0.5..2.0 per filter; values outside that range are
handled by chaining multiple filters. Returns "" when ``speed`` is 1.0.
+ Non-finite values (``inf``, ``nan``) are rejected: they pass a bare
+ ``> 0`` check and would either loop forever (``inf / 2.0`` stays
+ ``inf``) or produce an invalid tempo expression.
"""
+ if not math.isfinite(speed):
+ raise ValueError(f"Speed must be a finite number, got {speed}")
if speed <= 0:
raise ValueError(f"Speed must be a positive number, got {speed}")
if abs(speed - 1.0) < SPEED_EPSILON:
@@ -252,7 +258,10 @@ def build_concat_command(concat_list: Path, output_path: Path, output_format: st
raise ValueError("speed_path is required when speed is not 1.0")
return [
"ffmpeg", "-y", *inputs, "-filter_complex",
- f"[0:a]split=2[base][spd];[spd]{filters}[spdout]",
+ # asplit (not split): split is a video filter and rejects
+ # audio streams, which would fail every speed-adjusted
+ # assembly after synthesis had already completed.
+ f"[0:a]asplit=2[base][spd];[spd]{filters}[spdout]",
"-map", "[base]", *encode, *tags, *cover_block, *container, str(output_path),
"-map", "[spdout]", *encode, *tags, *cover_block, *container, str(speed_path),
]
@@ -290,7 +299,7 @@ def build_m4b_chapters_command(concat_list: Path, metadata_file: Path, output_pa
if speed_path is not None:
return [
"ffmpeg", "-y", *inputs, "-filter_complex",
- f"[0:a]split=2[base][spd];[spd]{atempo_filters(speed)}[spdout]",
+ f"[0:a]asplit=2[base][spd];[spd]{atempo_filters(speed)}[spdout]",
"-map", "[base]", *cover_block, "-map_metadata", "1", "-map_chapters", "1",
*encode, *tags, *container, str(output_path),
"-map", "[spdout]", *cover_block, "-map_metadata", "2", "-map_chapters", "2",
diff --git a/app/converter/chunking.py b/app/converter/chunking.py
index 0ffaa21..bf104bc 100644
--- a/app/converter/chunking.py
+++ b/app/converter/chunking.py
@@ -21,7 +21,8 @@ _FLOOR_RATIO = 0.7
# boundary; if such a token runs longer than this many characters per
# allowed word it is sliced at the hard character cap (a last-resort
# bound for punctuation-free input such as some CJK text or damage from
-# text extraction).
+# text extraction). The slice also honors the word limit itself, since
+# every non-spaced-script character counts as one word.
_MAX_CHARS_PER_WORD = 8
_ABBREVIATIONS = frozenset(
@@ -203,8 +204,8 @@ def _split_oversized(text: str, max_words: int) -> List[str]:
First at clause punctuation (kept attached for TTS prosody, and
never at commas without whitespace so tokens like "1,000,000" and
"12:30" survive), then at word boundaries. A token with no
- whitespace at all is sliced at the hard character cap so a
- punctuation-free run cannot exceed the bound.
+ whitespace at all is sliced at the hard character cap and the word
+ limit so a punctuation-free run cannot exceed either bound.
"""
parts = re.split(r"(?:(?<=[,;:])\s+|(?<=[,、;:]))", text)
pieces: List[str] = []
@@ -220,21 +221,50 @@ def _split_oversized(text: str, max_words: int) -> List[str]:
return pieces
+def _slice_token(word: str, max_words: int, cap: int) -> List[str]:
+ """Slice a single whitespace-free token into packable pieces.
+
+ Cuts before the character cap is exceeded and before the word count
+ is exceeded: each non-spaced-script character counts as one word,
+ so a punctuation-free CJK token is bounded by the word limit too,
+ not only by the character cap.
+ """
+ slices: List[str] = []
+ cur: List[str] = []
+ cur_chars = 0
+ # Any slice is itself one whitespace-delimited word before its
+ # non-spaced-script characters are counted on top.
+ cur_words = 1
+ for ch in word:
+ ch_words = 1 if _NON_SPACED_RUN.match(ch) else 0
+ if cur and (cur_chars + 1 > cap or cur_words + ch_words > max_words):
+ slices.append("".join(cur))
+ cur, cur_chars, cur_words = [], 0, 1
+ cur.append(ch)
+ cur_chars += 1
+ cur_words += ch_words
+ if cur:
+ slices.append("".join(cur))
+ return slices
+
+
def _hard_slices(text: str, max_words: int, cap: int) -> List[str]:
- """Slice TEXT at word boundaries, then at CAP characters when a
- single token has no whitespace to break at. Buffers never exceed
- the word limit or the character cap, so every piece is packable."""
+ """Slice TEXT at word boundaries, then within tokens when a single
+ token (no whitespace to break at) exceeds the character cap or the
+ word limit on its own. Buffers never exceed the word limit or the
+ character cap, so every piece is packable."""
pieces: List[str] = []
buf: List[str] = []
buf_words = 0
buf_chars = 0
for word in text.split():
- if len(word) > cap:
+ if len(word) > cap or _count_words(word) > max_words:
+ # A single token too big to buffer: slice it at the hard
+ # character cap and the word limit together.
if buf:
pieces.append(" ".join(buf))
buf, buf_words, buf_chars = [], 0, 0
- for start in range(0, len(word), cap):
- pieces.append(word[start:start + cap])
+ pieces.extend(_slice_token(word, max_words, cap))
continue
words = _count_words(word)
if buf and buf_words + words <= max_words \
@@ -266,6 +296,41 @@ def _joined_chars(units: List[_Unit]) -> int:
return total
+def _quote_states(pieces: List[str], in_quote_end: bool) -> List[bool]:
+ """The quote state after each piece of an oversized sentence.
+
+ Pieces of one sentence can sit inside a quotation the sentence does
+ not close, so ending a chunk at such a piece would sever the quote
+ (and marking them clean lets the target flush cut mid-quote). The
+ scanner's quote tracking is replayed over the pieces from the state
+ the sentence started in; that start state is unknown, so both
+ possibilities are replayed and the one whose end state matches the
+ unit's known end state wins (a unit rarely starts mid-quote). When
+ neither matches, every piece takes the unit's conservative end
+ state.
+ """
+ def simulate(in_quote: bool, depth: int) -> List[bool]:
+ states: List[bool] = []
+ for piece in pieces:
+ for ch in piece:
+ if ch == '"':
+ in_quote = not in_quote
+ elif ch in "“«":
+ depth += 1
+ elif ch in "”»":
+ depth = max(0, depth - 1)
+ states.append(in_quote or depth > 0)
+ return states
+
+ states = simulate(False, 0)
+ if states and states[-1] == in_quote_end:
+ return states
+ states = simulate(True, 0)
+ if states and states[-1] == in_quote_end:
+ return states
+ return [in_quote_end] * len(pieces)
+
+
def _split_smart(text: str, max_words: int) -> List[str]:
"""Smart strategy: fill chunks up to MAX_WORDS at sentence
boundaries, end them at the target once a quotation has closed or
@@ -318,9 +383,14 @@ def _split_smart(text: str, max_words: int) -> List[str]:
cur_chars = _joined_chars(cur)
continue
# One piece exceeds the limit itself: send its pieces out
- # packed as tightly as the pieces allow.
+ # packed as tightly as the pieces allow, carrying the
+ # sentence's quotation state through the fallback so a chunk
+ # can end at a clean in-sentence boundary — and a piece still
+ # inside a quote is never mistaken for one.
flush()
- for piece_text in _split_oversized(unit.text, max_words):
+ piece_texts = _split_oversized(unit.text, max_words)
+ quote_states = _quote_states(piece_texts, unit.in_quote_end)
+ for index, piece_text in enumerate(piece_texts):
if _count_words(piece_text) > max_words \
or len(piece_text) > char_cap:
# A character-sliced piece of degenerate input: its own
@@ -328,7 +398,10 @@ def _split_smart(text: str, max_words: int) -> List[str]:
flush()
chunks.append(piece_text)
continue
- piece = _Unit(piece_text, _count_words(piece_text), False, False)
+ piece = _Unit(
+ piece_text, _count_words(piece_text),
+ unit.para_after and index == len(piece_texts) - 1,
+ quote_states[index])
if cur and cur_words + piece.words <= max_words \
and cur_chars + 1 + len(piece_text) <= char_cap:
cur.append(piece)
diff --git a/app/converter/clients/base.py b/app/converter/clients/base.py
index 9cf6979..7e9d919 100644
--- a/app/converter/clients/base.py
+++ b/app/converter/clients/base.py
@@ -133,6 +133,11 @@ class BaseTTSClient:
try:
result = self.generate_chunk(text, chunk_num)
if result and Path(result).exists():
+ # A cancel that arrived while this request was in
+ # flight must still stop the run: the generated audio
+ # is scratch (cleaned per book) and must not be
+ # returned for assembly.
+ self._check_cancelled()
return Path(result)
logger.warning("Chunk %d attempt %d failed", chunk_num, attempt + 1)
except ConversionCancelled:
diff --git a/app/converter/converter.py b/app/converter/converter.py
index f8815d8..88e17a9 100644
--- a/app/converter/converter.py
+++ b/app/converter/converter.py
@@ -1,7 +1,9 @@
"""Orchestrates book-to-audiobook conversion."""
+import contextlib
import glob
import logging
+import math
import re
import shutil
import sys
@@ -64,6 +66,50 @@ CHUNKS_FOLDER = APP_DIR / "chunks" # Per-chunk scratch audio, cleaned per book
LOGS_FOLDER = logging_kit.LOG_DIR
DEBUG_FOLDER = APP_DIR / "debug" # --debug dumps, kept across runs
+
+@contextlib.contextmanager
+def _conversion_lock():
+ """An exclusive cross-process lock over the shared scratch folder.
+
+ Every converter run from this checkout synthesizes into the same
+ ``app/chunks`` directory (fixed chunk_NNNN names, cleaned per book), so
+ two overlapping runs would delete or overwrite each other's audio.
+ The lock makes a second run fail fast instead. It is an advisory
+ fcntl/msvcrt lock that dies with its process, so a crashed run never
+ leaves a stale lock wedging later starts; platforms without file
+ locking degrade to the old unlocked behavior.
+ """
+ CHUNKS_FOLDER.mkdir(parents=True, exist_ok=True)
+ handle = open(CHUNKS_FOLDER / ".conversion.lock", "w")
+ handle.write("0")
+ handle.flush()
+ try:
+ busy: Optional[OSError] = None
+ try:
+ import fcntl
+ try:
+ fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
+ except OSError as exc:
+ busy = exc
+ except ImportError:
+ try:
+ import msvcrt
+ try:
+ msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
+ except OSError as exc:
+ busy = exc
+ except ImportError:
+ logger.warning("No file locking on this platform; the "
+ "conversion lock is not enforced")
+ if busy is not None:
+ raise RuntimeError(
+ "Another conversion is already running from this "
+ "installation: the shared scratch folder (app/chunks) is "
+ "in use. Wait for the run to finish or stop it first.")
+ yield
+ finally:
+ handle.close() # releases the lock
+
# Output containers and supported input formats.
AUDIO_FORMATS = ("mp3", "m4b", "ogg", "flac")
SUPPORTED_FORMATS = [".txt", ".pdf", ".epub"]
@@ -322,8 +368,12 @@ class AudiobookConverter:
unload_models: Optional[bool] = None,
progress: Optional[Callable[[dict], None]] = None,
cancel=None):
- if speed <= 0:
- raise ValueError(f"Speed must be a positive number, got {speed}")
+ # Non-finite speeds pass a bare "> 0" check (and float("inf") is
+ # "positive"): they would loop forever in atempo filter chaining,
+ # so they are rejected here at the boundary.
+ if not math.isfinite(speed) or speed <= 0:
+ raise ValueError(
+ f"Speed must be a finite positive number, got {speed}")
if output_format not in AUDIO_FORMATS:
raise ValueError(f"Unsupported output format: {output_format}")
if backend is None:
@@ -767,6 +817,9 @@ class AudiobookConverter:
return False
output_path = AUDIOBOOKS_FOLDER / f"{stem}.{self.output_format}"
+ # Same rule as _convert_text: honor a cancel that arrived during
+ # the final chapter's synthesis instead of assembling anyway.
+ self._check_cancelled()
if not audio.combine_chapters_to_m4b(chapter_files, titles, output_path,
chunks_dir=CHUNKS_FOLDER,
speed=self.speed,
@@ -910,6 +963,9 @@ class AudiobookConverter:
successful_chunks, total_chunks)
return False
+ # A cancel that arrived while the last chunk was generating
+ # must still stop the run here, before any output is produced.
+ self._check_cancelled()
success = audio.combine_chunks(total_chunks, output_path,
chunk_results=results,
chunks_dir=CHUNKS_FOLDER,
@@ -1087,6 +1143,27 @@ class AudiobookConverter:
tag = f"{name_tag}_{narrator_tag}" if name_tag else narrator_tag
names.append((book_file, f"{name}_{tag}"))
+ # The stem-collision suffix above can itself collide with another
+ # book's natural stem ("book.txt" and "book_txt.txt" both produce
+ # "book_txt_<tag>"), so make every planned name unique across the
+ # batch: two books sharing an output name would silently overwrite
+ # each other (ffmpeg writes with -y and both are approved here,
+ # before any output exists). Later occurrences get a numeric
+ # suffix; the list is sorted, so the result is deterministic.
+ used: set = set()
+ unique: List[Tuple[Path, str]] = []
+ for book_file, name in names:
+ if name in used:
+ suffix = 2
+ while f"{name}_{suffix}" in used:
+ suffix += 1
+ print(f"[INFO] Output name {name} is already planned for "
+ f"another book; {book_file.name} becomes {name}_{suffix}")
+ name = f"{name}_{suffix}"
+ used.add(name)
+ unique.append((book_file, name))
+ names = unique
+
# Ask every overwrite question up front, before any conversion
# starts, so the rest of the run is unattended.
planned: List[Tuple[Path, str]] = []
@@ -1136,6 +1213,19 @@ class AudiobookConverter:
self._emit({"kind": "done", "ok": 0, "total": 0})
return True
+ # The scratch folder is shared by every run from this checkout;
+ # hold the cross-process lock for the whole conversion.
+ with _conversion_lock():
+ return self._convert_planned_books(book_files, planned, run_start)
+
+ def _convert_planned_books(self, book_files, planned,
+ run_start: float) -> bool:
+ """Convert PLANNED books sequentially (the body of ``run``).
+
+ BOOK_FILES/PLANNED come from the pre-flight (outer or caller);
+ RUN_START timestamps the console summary. Returns True when every
+ planned book converted.
+ """
self._say(f"[INFO] Converting {len(planned)} of {len(book_files)} book(s)")
# Per-book outcome ({book file name: ok}), published on the
diff --git a/app/converter/extractors.py b/app/converter/extractors.py
index 8b01372..0e30ac1 100644
--- a/app/converter/extractors.py
+++ b/app/converter/extractors.py
@@ -2,11 +2,13 @@
import codecs
import logging
+import posixpath
import re
import zipfile
from html import unescape
from pathlib import Path
-from typing import List, NamedTuple, Optional
+from typing import Dict, List, NamedTuple, Optional
+from urllib.parse import unquote
try:
from bs4 import BeautifulSoup
@@ -216,8 +218,13 @@ def _epub_spine_order(epub_zip: zipfile.ZipFile, html_names: List[str]):
Parses the package OPF (located via META-INF/container.xml, else the
only *.opf member): manifest item id -> href, then the spine's
- idrefs. Returns member paths limited to HTML_NAMES; nav documents
- (``properties`` containing "nav") and non-HTML items are excluded.
+ idrefs. Hrefs are URL-encoded, may carry "./" or "../" segments and
+ XML entities, and are relative to the OPF's folder — they are
+ normalized before matching against the zip's member names, so valid
+ chapters are not silently dropped when their written form differs
+ from the archived path. Returns member paths limited to HTML_NAMES;
+ nav documents (``properties`` containing "nav") and non-HTML items
+ are excluded.
"""
container = "META-INF/container.xml"
opf_name = None
@@ -246,6 +253,25 @@ def _epub_spine_order(epub_zip: zipfile.ZipFile, html_names: List[str]):
base = "/".join(opf_name.split("/")[:-1])
item_tags = re.findall(r"<item\b[^>]*>", opf)
+ # Zip member names indexed under their normalized spellings, so an
+ # exact archive path can be found from any equivalent href form.
+ # (Percent-encoded and plain spellings both map to the same member.)
+ members: Dict[str, str] = {}
+ for name in html_names:
+ members.setdefault(posixpath.normpath(name), name)
+ members.setdefault(posixpath.normpath(unquote(name)), name)
+
+ def member_for_href(href: str) -> Optional[str]:
+ """The zip member an OPF manifest HREF points at (best effort)."""
+ path = unescape(href).strip()
+ path = path.split("#", 1)[0]
+ path = unquote(path).strip()
+ if not path:
+ return None
+ if base:
+ path = f"{base}/{path}"
+ return members.get(posixpath.normpath(path).lstrip("/"))
+
def is_nav(item_tag: str) -> bool:
properties = attr(item_tag, "properties") or ""
return "nav" in properties.split()
@@ -262,10 +288,9 @@ def _epub_spine_order(epub_zip: zipfile.ZipFile, html_names: List[str]):
href = attr(match, "href")
if not href:
continue
- path = (f"{base}/{href}" if base else href)
- path = re.sub(r"#.*$", "", path)
- if path in html_names and path not in order:
- order.append(path)
+ member = member_for_href(href)
+ if member and member not in order:
+ order.append(member)
return order or None
@@ -287,45 +312,82 @@ def _read_epub_manual(file_path: Path):
def clean_text(text: str) -> str:
- """Normalize whitespace and strip standalone page numbers.
+ """Normalize whitespace, strip standalone page numbers, and keep
+ paragraph breaks.
Page numbers are removed only when a short number (up to three digits)
appears alone on its own line (before whitespace collapsing), so inline
numbers like "42 years", "1,000" or "3.5" are preserved, as are
four-digit standalone lines, which are usually years ("1984") or
chapter numbers rather than page numbers.
+
+ Runs of blank lines survive as a single paragraph break (``\\n\\n``) so
+ the smart chunker can still end requests at paragraph boundaries and
+ reset its quotation state there; single line breaks (soft wraps) and
+ all other whitespace collapse to spaces.
"""
if not text:
return ""
# Standalone page numbers (digits alone on a line) must go BEFORE the
- # newline-collapsing step below.
+ # whitespace-collapsing steps below.
text = re.sub(r"(?m)^\s*\d{1,3}\s*$", " ", text)
- text = re.sub(r"\s+", " ", text)
+ # Normalize line breaks so the steps below see plain "\n".
+ text = re.sub(r"\r\n?", "\n", text)
+ # Any run of blank lines becomes exactly one paragraph break.
+ text = re.sub(r"\n[^\S\n]*\n(?:[^\S\n]*\n)*", "\n\n", text)
+ # Remaining single line breaks (soft wraps) become spaces.
+ text = re.sub(r"(?<!\n)\n(?!\n)", " ", text)
+ # Collapse every other whitespace run (including nbsp) to one space.
+ text = re.sub(r"[^\S\n]+", " ", text)
+ # Trim whatever sits against a paragraph break.
+ text = re.sub(r"[^\S\n]*\n\n[^\S\n]*", "\n\n", text)
return text.strip()
+# Block-level elements structure the document into paragraphs and lines:
+# replacing them with a paragraph break keeps the chunker's paragraph
+# boundaries (and headings separated from prose). Inline elements must NOT
+# break words, so they are left for text extraction to unwrap without
+# spaces ("un<em>believ</em>able" stays one word). The lookahead keeps
+# lookalike tags (<parameter>, <preheat>) out of the match.
+_BLOCK_BREAK_RE = re.compile(
+ r"</?(?:address|article|aside|blockquote|body|br|caption|center|col"
+ r"|colgroup|dd|div|dl|dt|fieldset|figcaption|figure|footer|form"
+ r"|h[1-6]|head|header|hr|html|legend|li|main|nav|ol|option|p|pre"
+ r"|section|summary|table|tbody|td|tfoot|th|thead|tr|ul)(?=[\s/>])"
+ r"[^>]*>",
+ re.IGNORECASE)
+
+
def clean_html(html_content: str) -> str:
- """Strip markup, scripts and styles from HTML content."""
+ """Strip markup, scripts and styles from HTML content.
+
+ Block-level tags become paragraph breaks (so the chunker sees the
+ document's paragraphs); inline tags vanish without adding spaces, so
+ no artificial word boundaries are injected.
+ """
if not html_content:
return ""
+ html_content = _BLOCK_BREAK_RE.sub("\n\n", html_content)
+
if BS4_AVAILABLE:
try:
soup = BeautifulSoup(html_content, "html.parser")
for tag in soup(["script", "style"]):
tag.decompose()
- text = soup.get_text(separator=" ")
- return re.sub(r"\s+", " ", text).strip()
+ return clean_text(soup.get_text(separator=""))
except Exception as exc:
logger.debug("BeautifulSoup cleaning failed, falling back to regex: %s", exc)
- # Fallback regex cleaning
+ # Fallback regex cleaning. Block tags are already paragraph breaks;
+ # every other tag (inline) disappears without adding a space, so no
+ # artificial word boundary is injected.
html_content = re.sub(r"<style[^>]*>.*?</style>", "", html_content, flags=re.DOTALL | re.IGNORECASE)
html_content = re.sub(r"<script[^>]*>.*?</script>", "", html_content, flags=re.DOTALL | re.IGNORECASE)
- html_content = re.sub(r"<[^>]+>", " ", html_content)
+ html_content = re.sub(r"<[^>]+>", "", html_content)
html_content = unescape(html_content)
- html_content = re.sub(r"\s+", " ", html_content)
- return html_content.strip()
+ return clean_text(html_content)
def _natural_key(name: str):
diff --git a/app/tests/test_audio.py b/app/tests/test_audio.py
index 75de97c..04a6afc 100644
--- a/app/tests/test_audio.py
+++ b/app/tests/test_audio.py
@@ -51,6 +51,36 @@ class AtempoFiltersTests(unittest.TestCase):
with self.assertRaises(ValueError):
atempo_filters(-1.5)
+ def test_infinite_speed_rejected(self):
+ # inf passes a bare positivity check; without the finite guard
+ # atempo chaining would loop forever (inf / 2.0 stays inf).
+ with self.assertRaises(ValueError):
+ atempo_filters(float("inf"))
+
+ def test_overflowing_speed_rejected(self):
+ with self.assertRaises(ValueError):
+ atempo_filters(1e309)
+
+ def test_nan_speed_rejected(self):
+ with self.assertRaises(ValueError):
+ atempo_filters(float("nan"))
+
+ def test_asplit_used_for_audio_streams(self):
+ # split is a video filter and rejects an audio stream, so a
+ # speed-adjusted copy needs asplit — in both command builders.
+ concat_cmd = build_concat_command(
+ Path("/tmp/_concat.txt"), Path("/tmp/out.mp3"), "mp3",
+ speed=1.5, speed_path=Path("/tmp/out_1.5.mp3"))
+ m4b_cmd = build_m4b_chapters_command(
+ Path("/tmp/_concat.txt"), Path("/tmp/_meta.txt"),
+ Path("/tmp/out.m4b"), speed=1.5,
+ speed_path=Path("/tmp/out_1.5.m4b"),
+ speed_metadata_file=Path("/tmp/_meta2.txt"))
+ for cmd in (concat_cmd, m4b_cmd):
+ filter_complex = cmd[cmd.index("-filter_complex") + 1]
+ self.assertIn("[0:a]asplit=2", filter_complex)
+ self.assertNotIn("[0:a]split=2", filter_complex)
+
class CleanupChunksTests(unittest.TestCase):
def test_removes_only_chunk_files(self):
diff --git a/app/tests/test_backends_audiocpp.py b/app/tests/test_backends_audiocpp.py
index 9392ba1..1800267 100644
--- a/app/tests/test_backends_audiocpp.py
+++ b/app/tests/test_backends_audiocpp.py
@@ -252,7 +252,7 @@ class UpdateConfigPortTests(unittest.TestCase):
self.assertTrue(changed)
text = self.config_path.read_text(encoding="utf-8")
self.assertIn(
- 'AUDIOCPP_API_URL = "http://127.0.0.1:8080" # audio.cpp audiocpp_server',
+ "AUDIOCPP_API_URL = 'http://127.0.0.1:8080' # audio.cpp audiocpp_server",
text)
self.assertIn('LANGUAGE = "English"', text)
self.assertIn("CHUNK_SIZE = 250", text)
diff --git a/app/tests/test_backends_servers.py b/app/tests/test_backends_servers.py
index 9fd71f2..283df95 100644
--- a/app/tests/test_backends_servers.py
+++ b/app/tests/test_backends_servers.py
@@ -370,19 +370,44 @@ class ReapTests(unittest.TestCase):
class KillPidTests(unittest.TestCase):
- """_kill_pid: the reap check ends the grace wait before SIGKILL."""
+ """_kill_pid: the group probe decides the wait's end, not the reap.
- def test_reaped_child_ends_wait_without_sigkill(self):
+ Reaping the launcher proves nothing about the rest of its process
+ group: workers can outlive it and must still get the SIGKILL
+ escalation.
+ """
+
+ def test_empty_group_ends_wait_without_sigkill(self):
+ # Single-process server: the launcher is reaped and the group is
+ # empty (killpg(0) raises) — success immediately, no SIGKILL.
with patch("os.getpgid", return_value=4242), \
- patch("os.killpg") as mk_killpg, \
- patch("os.waitpid", return_value=(4242, 0)) as mk_waitpid, \
+ patch("os.killpg",
+ side_effect=[None, ProcessLookupError]) as mk_killpg, \
+ patch("os.waitpid", return_value=(4242, 0)), \
patch("time.sleep") as mk_sleep:
ok = servers._kill_pid(4242)
self.assertTrue(ok)
- mk_killpg.assert_called_once_with(4242, signal.SIGTERM)
- mk_waitpid.assert_called_once_with(4242, os.WNOHANG)
+ self.assertEqual(mk_killpg.call_args_list[0].args,
+ (4242, signal.SIGTERM))
+ self.assertEqual(mk_killpg.call_args_list[-1].args, (4242, 0))
mk_sleep.assert_not_called()
+ def test_reaped_leader_with_live_workers_escalates_to_sigkill(self):
+ # A launcher that dies on SIGTERM while its workers ignore it:
+ # reaping the leader must not end the stop, the surviving group
+ # keeps burning the grace period and then gets SIGKILLed.
+ with patch("os.getpgid", return_value=4242), \
+ patch("os.killpg", return_value=None) as mk_killpg, \
+ patch("os.waitpid", return_value=(4242, 0)), \
+ patch("time.sleep"):
+ ok = servers._kill_pid(4242)
+ self.assertTrue(ok)
+ calls = mk_killpg.call_args_list
+ self.assertEqual(calls[0].args, (4242, signal.SIGTERM))
+ self.assertEqual(calls[-1].args, (4242, signal.SIGKILL))
+ # The group was probed repeatedly while waiting for the workers.
+ self.assertGreater(len(calls), 2)
+
def test_escalates_to_sigkill_when_child_stays_alive(self):
with patch("os.getpgid", return_value=4242), \
patch("os.killpg") as mk_killpg, \
diff --git a/app/tests/test_chunking.py b/app/tests/test_chunking.py
index bcae6b2..5c6f60b 100644
--- a/app/tests/test_chunking.py
+++ b/app/tests/test_chunking.py
@@ -203,6 +203,47 @@ class SplitIntoChunksTests(unittest.TestCase):
self.assertTrue(all(len(c) <= 250 * 8 for c in chunks))
self.assertEqual("".join(chunks), text)
+ def test_non_spaced_script_respects_the_word_limit(self):
+ # A punctuation-free CJK token longer than the limit counts one
+ # word per character: it must be sliced by the word limit too,
+ # not only by the character cap (which alone would allow chunks
+ # up to CHUNK_SIZE * 8 characters).
+ from converter.chunking import _count_words
+
+ text = "字" * 500
+ chunks = split_into_chunks(text, max_words=250, smart=True)
+ self.assertGreater(len(chunks), 1)
+ self.assertTrue(all(_count_words(c) <= 250 for c in chunks),
+ [(_count_words(c), c[:20]) for c in chunks])
+ self.assertEqual("".join(chunks), text)
+
+ text = "字" * 5000
+ chunks = split_into_chunks(text, max_words=250, smart=True)
+ self.assertTrue(all(_count_words(c) <= 250 for c in chunks),
+ [(_count_words(c), c[:20]) for c in chunks])
+ self.assertEqual("".join(chunks), text)
+
+ def test_oversized_sentence_keeps_quote_state_through_fallback(self):
+ # An oversized opening sentence of a dialogue must not present
+ # its mid-quote remainder as a clean boundary: the fallback
+ # carries the sentence's quote state, so the close of the quote
+ # stays attached to the words before it in the next chunk.
+ text = "“" + "go " * 17 + "go. End now.”"
+ chunks = split_into_chunks(text, max_words=10, smart=True)
+ self._assert_bounded(chunks, 10)
+ self._assert_content(text, chunks)
+ self.assertEqual([len(c.split()) for c in chunks], [10, 10])
+
+ def test_oversized_sentence_breaks_cleanly_when_quote_closes_mid_sentence(self):
+ # A quote opened and closed inside one long sentence: the
+ # clause-split pieces after the closing quote are clean
+ # boundaries and can be packed together.
+ text = ("He answered " + "very " * 8 + "“calmly” and then kept "
+ "talking on and on for a while.")
+ chunks = split_into_chunks(text, max_words=6, smart=True)
+ self._assert_bounded(chunks, 6)
+ self._assert_content(text, chunks)
+
def test_non_spaced_script_stays_bounded(self):
# CJK text: sentence terminators break units without
# whitespace, and each ideograph counts as a word.
diff --git a/app/tests/test_cleaning.py b/app/tests/test_cleaning.py
index 41f4ed7..40f2d53 100644
--- a/app/tests/test_cleaning.py
+++ b/app/tests/test_cleaning.py
@@ -10,8 +10,24 @@ class CleanTextTests(unittest.TestCase):
self.assertEqual(clean_text(""), "")
self.assertEqual(clean_text(None), "")
- def test_collapses_whitespace(self):
- self.assertEqual(clean_text("a\n\n b \t c"), "a b c")
+ def test_collapses_whitespace_inside_paragraphs(self):
+ self.assertEqual(clean_text("a b \t c"), "a b c")
+
+ def test_paragraph_breaks_survive(self):
+ # Blank lines are the smart chunker's paragraph boundaries (and
+ # reset its quotation state), so they survive cleaning while a
+ # run of blank lines collapses to a single break.
+ self.assertEqual(clean_text("a\n\n b \t c"), "a\n\nb c")
+ self.assertEqual(clean_text("a\n\n\n\nb"), "a\n\nb")
+ self.assertEqual(clean_text("a\n \nb"), "a\n\nb")
+ self.assertEqual(clean_text("line one\nline two"), "line one line two")
+ self.assertEqual(clean_text("a \n\n b"), "a\n\nb")
+
+ def test_paragraph_breaks_not_glued_to_text(self):
+ self.assertEqual(
+ clean_text("End of chapter.\n\n\n New chapter. \n\nStarts here."),
+ "End of chapter.\n\nNew chapter.\n\nStarts here.",
+ )
def test_preserves_inline_numbers(self):
self.assertEqual(clean_text("He was 42 years old."), "He was 42 years old.")
@@ -23,14 +39,16 @@ class CleanTextTests(unittest.TestCase):
)
def test_removes_standalone_page_numbers(self):
+ # The page number's own line becomes a paragraph break (a safe
+ # chunk boundary), not a glued sentence.
self.assertEqual(
clean_text("End of page.\n7\nNext page text."),
- "End of page. Next page text.",
+ "End of page.\n\nNext page text.",
)
- def test_page_number_removal_leaves_single_spacing(self):
+ def test_page_number_removal_leaves_paragraph_break(self):
result = clean_text("Chapter one\n\n12\n\nChapter two")
- self.assertEqual(result, "Chapter one Chapter two")
+ self.assertEqual(result, "Chapter one\n\nChapter two")
self.assertNotIn(" ", result)
@@ -49,6 +67,39 @@ class CleanHtmlTests(unittest.TestCase):
self.assertEqual(clean_html(""), "")
self.assertEqual(clean_html(None), "")
+ def test_inline_markup_never_splits_words(self):
+ # Inline tags must not inject spaces mid-word (they become chunk
+ # boundaries and corrupt pronunciation).
+ self.assertEqual(
+ clean_html("<p>He was un<em>believ</em>able and didn<i>'</i>t stop.</p>"),
+ "He was unbelievable and didn't stop.",
+ )
+
+ def test_block_tags_become_paragraph_breaks(self):
+ self.assertEqual(
+ clean_html("<p>First para.</p><p>Second para.</p><h2>Head</h2>"),
+ "First para.\n\nSecond para.\n\nHead",
+ )
+
+ def test_div_sections_keep_paragraph_boundaries(self):
+ html = ('<div>“An unfinished quotation.</div>'
+ '<div>A new paragraph outside the quotation.</div>')
+ self.assertEqual(
+ clean_html(html),
+ "“An unfinished quotation.\n\nA new paragraph outside the quotation.",
+ )
+
+ def test_table_cells_do_not_glue(self):
+ self.assertEqual(
+ clean_html("<table><tr><td>A</td><td>B</td></tr>"
+ "<tr><td>C</td><td>D</td></tr></table>"),
+ "A\n\nB\n\nC\n\nD",
+ )
+
+ def test_regex_fallback_matches_bs4_behavior(self):
+ html = "<p>un<em>believ</em>able</p><div>After a block.</div>"
+ self.assertEqual(clean_html(html), "unbelievable\n\nAfter a block.")
+
if __name__ == "__main__":
unittest.main()
diff --git a/app/tests/test_converter.py b/app/tests/test_converter.py
index 9084898..3795fbc 100644
--- a/app/tests/test_converter.py
+++ b/app/tests/test_converter.py
@@ -709,6 +709,25 @@ class PreflightOverwritesTests(unittest.TestCase):
names = {name for _book, name in planned}
self.assertEqual(names, {"book_txt_m1_Vivian", "book_epub_m1_Vivian"})
+ def test_planned_names_are_unique_across_the_batch(self):
+ # "book.txt" and "book_txt.txt" both resolve to the stem
+ # "book_txt" (the suffix disambiguation's target), which without
+ # a uniqueness pass would make the later book silently overwrite
+ # the earlier one's audiobook.
+ (converter_mod.BOOKS_FOLDER / "book.epub").write_text("x",
+ encoding="utf-8")
+ (converter_mod.BOOKS_FOLDER / "book.txt").write_text("x",
+ encoding="utf-8")
+ (converter_mod.BOOKS_FOLDER / "book_txt.txt").write_text(
+ "x", encoding="utf-8")
+ with patch("builtins.input", side_effect=AssertionError("should not prompt")):
+ _book_files, planned = AudiobookConverter.preflight_overwrites(
+ BACKEND_QWEN, "Vivian", VOICE_MODE_CUSTOM, None, "mp3")
+ names = [name for _book, name in planned]
+ self.assertEqual(len(names), len(set(names)))
+ self.assertEqual(names, ["book_epub_Vivian", "book_txt_Vivian",
+ "book_txt_Vivian_2"])
+
class ComputeModelTagTests(unittest.TestCase):
"""compute_model_tag: the sanitized model id used in output names."""
diff --git a/app/tests/test_extractors.py b/app/tests/test_extractors.py
index 619fe5a..b7f14d9 100644
--- a/app/tests/test_extractors.py
+++ b/app/tests/test_extractors.py
@@ -133,6 +133,51 @@ class EpubZipfileFallbackTests(unittest.TestCase):
self.assertEqual([title for title, _ in items], ["a", "b"])
+ def test_spine_hrefs_are_normalized_before_matching(self):
+ # Hrefs are URL-encoded, entity-escaped and relative (possibly
+ # with ./ or ../ segments): every equivalent spelling must
+ # resolve to its archived chapter, or the chapter silently
+ # disappears from the book.
+ import zipfile
+
+ from converter.extractors import _read_epub_zipfile
+
+ container = ("<?xml version=\"1.0\"?>"
+ "<container><rootfiles>"
+ "<rootfile full-path=\"OEBPS/content.opf\"/>"
+ "</rootfiles></container>")
+ opf = ("<?xml version=\"1.0\"?>"
+ "<package xmlns=\"http://www.idpf.org/2007/opf\">"
+ "<manifest>"
+ "<item id=\"nav\" href=\"nav.xhtml\" properties=\"nav\"/>"
+ "<item id=\"c1\" href=\"./text/chapterA.xhtml\"/>"
+ "<item id=\"c2\" href=\"../OEBPS/text/chapterB.xhtml\"/>"
+ "<item id=\"c3\" href=\"text/chapter&#37;20C.xhtml\"/>"
+ "</manifest>"
+ "<spine><itemref idref=\"nav\"/>"
+ "<itemref idref=\"c1\"/><itemref idref=\"c2\"/>"
+ "<itemref idref=\"c3\"/></spine>"
+ "</package>")
+ with tempfile.TemporaryDirectory() as tmp:
+ path = Path(tmp) / "book.epub"
+ with zipfile.ZipFile(path, "w") as zf:
+ zf.writestr("mimetype", "application/epub+zip")
+ zf.writestr("META-INF/container.xml", container)
+ zf.writestr("OEBPS/content.opf", opf)
+ zf.writestr("OEBPS/nav.xhtml",
+ "<html><body><p>Contents</p></body></html>")
+ zf.writestr("OEBPS/text/chapterA.xhtml",
+ "<html><body><p>Alpha text.</p></body></html>")
+ zf.writestr("OEBPS/text/chapterB.xhtml",
+ "<html><body><p>Beta text.</p></body></html>")
+ zf.writestr("OEBPS/text/chapter%20C.xhtml",
+ "<html><body><p>Gamma text.</p></body></html>")
+ items = _read_epub_zipfile(path)
+
+ titles = [title for title, _ in items]
+ self.assertNotIn("nav", titles)
+ self.assertEqual(titles, ["chapterA", "chapterB", "chapter%20C"])
+
def _build_test_epub(path: Path, chapters=(("One", "First chapter text."),
("Two", "Second chapter text."))) -> None:
@@ -183,15 +228,20 @@ class EpubExtractionTests(unittest.TestCase):
@requires_epub
def test_epub_extraction_follows_spine_order(self):
+ # extract_text() only handles TXT and PDF; EPUB books (with their
+ # per-chapter structure) come through extract_sections().
+ from converter.extractors import extract_sections
+
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "book.epub"
_build_test_epub(path)
- text = extract_text(path)
+ sections = extract_sections(path)
- self.assertIn("First chapter text.", text)
- self.assertIn("Second chapter text.", text)
- self.assertLess(text.index("First chapter text."),
- text.index("Second chapter text."))
+ texts = [section.text for section in sections]
+ self.assertIn("First chapter text.", " ".join(texts))
+ self.assertIn("Second chapter text.", " ".join(texts))
+ self.assertLess(" ".join(texts).index("First chapter text."),
+ " ".join(texts).index("Second chapter text."))
class ExtractSectionsTests(unittest.TestCase):
diff --git a/app/tests/test_hub.py b/app/tests/test_hub.py
index cbcdc8a..183e8aa 100644
--- a/app/tests/test_hub.py
+++ b/app/tests/test_hub.py
@@ -3565,12 +3565,13 @@ class SettingsTests(unittest.TestCase):
self.assertTrue(hub.common.update_config_value(
key, value, config_path=path))
text = path.read_text(encoding="utf-8")
+ # Strings render as proper Python literals (repr).
self.assertEqual(
text,
"# Default output options\n"
- 'AUDIO_FORMAT = "mp3"\n'
- 'AUDIO_BITRATE = "192k"\n'
- 'LANGUAGE = "Japanese"\n'
+ "AUDIO_FORMAT = 'mp3'\n"
+ "AUDIO_BITRATE = '192k'\n"
+ "LANGUAGE = 'Japanese'\n"
"\n"
"CHUNK_SIZE = 300 # words per request\n")
# The imported module mirrors the file immediately.
@@ -4117,10 +4118,11 @@ class SettingsTests(unittest.TestCase):
text = path.read_text(encoding="utf-8")
self.assertIn('AUDIO_FORMAT = "m4b"', text)
self.assertIn("CHUNK_SIZE = 300", text)
- # The settings-only fields are written back unchanged.
- self.assertIn('INPUT_DIR = "', text)
- self.assertIn('OUTPUT_DIR = "', text)
- self.assertIn('CLONE_WAV_DIR = "', text)
+ # The settings-only fields are written back (as repr string
+ # literals for the values the menu saved).
+ self.assertIn("INPUT_DIR = '/workspace/input'", text)
+ self.assertIn("OUTPUT_DIR = '/workspace/output'", text)
+ self.assertIn("CLONE_WAV_DIR = '/workspace/voices'", text)
self.assertIn("SPEED = 1.0", text)
self.assertIn("DEBUG = False", text)
# The running session also picked up the change in-memory.
@@ -4147,9 +4149,33 @@ class SettingsTests(unittest.TestCase):
("AUDIOCPP_API_URL", "http://127.0.0.1:8081")):
hub.common.update_config_value(key, value, config_path=path)
text = path.read_text(encoding="utf-8")
- self.assertIn('QWEN_API_URL = "http://127.0.0.1:7862"', text)
- self.assertIn('FASTER_API_URL = "http://127.0.0.1:8001"', text)
- self.assertIn('AUDIOCPP_API_URL = "http://127.0.0.1:8081"', text)
+ self.assertIn("QWEN_API_URL = 'http://127.0.0.1:7862'", text)
+ self.assertIn("FASTER_API_URL = 'http://127.0.0.1:8001'", text)
+ self.assertIn("AUDIOCPP_API_URL = 'http://127.0.0.1:8081'", text)
+
+ def test_update_config_value_escapes_quotes_and_backslashes(self):
+ # Strings containing quotes or backslashes must stay valid,
+ # unchanging Python: bare double-quote quoting would corrupt
+ # config.py (invalidating every later start) or silently alter
+ # the value once backslashes became escapes.
+ import tempfile
+ self._snapshot_settings()
+ with tempfile.TemporaryDirectory() as td:
+ path = Path(td) / "config.py"
+ path.write_text('INPUT_DIR = "input"\n', encoding="utf-8")
+ self.assertTrue(hub.common.update_config_value(
+ "INPUT_DIR", '/books/A "quoted" title\\', config_path=path))
+ text = path.read_text(encoding="utf-8")
+ compiled = compile(text, str(path), "exec")
+ scope = {}
+ exec(compiled, scope)
+ # The matcher must still find the (now weirdly quoted) value
+ # to update it again.
+ self.assertTrue(hub.common.update_config_value(
+ "INPUT_DIR", "plain", config_path=path))
+ self.assertIn("INPUT_DIR = 'plain'",
+ path.read_text(encoding="utf-8"))
+ self.assertEqual(scope["INPUT_DIR"], '/books/A "quoted" title\\')
class AudiocppServerConfigTests(unittest.TestCase):
diff --git a/audiobook.py b/audiobook.py
index a610caa..19f5290 100755
--- a/audiobook.py
+++ b/audiobook.py
@@ -19,6 +19,7 @@ externally-run server and never touches server state.
import argparse
import logging
+import math
import sys
import traceback
from pathlib import Path
@@ -726,14 +727,15 @@ Examples:
args = parser.parse_args()
# An explicit --speed overrides the config SPEED setting; either way
- # the value must be a positive number.
+ # the value must be a finite positive number (a bare positivity check
+ # lets --speed inf / 1e309 through, which would loop forever building
+ # ffmpeg atempo filters during assembly).
speed = args.speed if args.speed is not None else config.SPEED
- try:
- bad_speed = not isinstance(speed, (int, float)) or speed <= 0
- except TypeError:
- bad_speed = True
+ bad_speed = (not isinstance(speed, (int, float))
+ or not math.isfinite(speed)
+ or speed <= 0)
if bad_speed:
- parser.error(f"--speed must be a positive number (got {speed!r})")
+ parser.error(f"--speed must be a finite positive number (got {speed!r})")
# The directory flags and the single-book flags are two different
# ways to choose what to convert and where it goes; mixing a pair
@@ -841,27 +843,36 @@ Examples:
if not args.clone and (args.transcription or args.no_transcription):
print("[WARNING] --transcription/--no-transcription "
"are ignored without --clone")
- try:
- from backends.sglomni import models as sg_models
- installed = sg_models.installed_keys()
- except Exception:
- installed = None
- if installed is not None:
- if not installed:
- parser.error("--backend sglomni: no models are downloaded "
- "— install one via the TUI's Configure "
- "Backends → SGLang-Omni first")
- if args.model is None:
- if len(installed) > 1:
- parser.error(
- "--backend sglomni requires --model when several "
- "models are installed (installed: "
- f"{', '.join(installed)})")
- args.model = installed[0]
- elif sg_models.entry_by_key(args.model) is None:
- parser.error(f"--model {args.model!r} is not an "
- "SGLang-Omni catalog key (installed: "
- f"{', '.join(installed)})")
+ if args.api_url is not None:
+ # A remote server brings its own models: local installs are
+ # irrelevant here, but the catalog key to request must be
+ # named (the converter has no default for remote runs).
+ if not args.model:
+ parser.error("--backend sglomni with --api-url requires "
+ "--model (a catalog key, e.g. "
+ "higgs_audio_v3_tts; see the backend docs)")
+ else:
+ try:
+ from backends.sglomni import models as sg_models
+ installed = sg_models.installed_keys()
+ except Exception:
+ installed = None
+ if installed is not None:
+ if not installed:
+ parser.error("--backend sglomni: no models are downloaded "
+ "— install one via the TUI's Configure "
+ "Backends → SGLang-Omni first")
+ if args.model is None:
+ if len(installed) > 1:
+ parser.error(
+ "--backend sglomni requires --model when several "
+ "models are installed (installed: "
+ f"{', '.join(installed)})")
+ args.model = installed[0]
+ elif sg_models.entry_by_key(args.model) is None:
+ parser.error(f"--model {args.model!r} is not an "
+ "SGLang-Omni catalog key (installed: "
+ f"{', '.join(installed)})")
if args.clone and not Path(args.clone).is_file():
parser.error(f"--clone: no such reference audio file: {args.clone}")
else: