diff options
| -rw-r--r-- | app/backends/common.py | 46 | ||||
| -rw-r--r-- | app/backends/servers.py | 189 | ||||
| -rw-r--r-- | app/converter/audio.py | 13 | ||||
| -rw-r--r-- | app/converter/chunking.py | 97 | ||||
| -rw-r--r-- | app/converter/clients/base.py | 5 | ||||
| -rw-r--r-- | app/converter/converter.py | 94 | ||||
| -rw-r--r-- | app/converter/extractors.py | 96 | ||||
| -rw-r--r-- | app/tests/test_audio.py | 30 | ||||
| -rw-r--r-- | app/tests/test_backends_audiocpp.py | 2 | ||||
| -rw-r--r-- | app/tests/test_backends_servers.py | 37 | ||||
| -rw-r--r-- | app/tests/test_chunking.py | 41 | ||||
| -rw-r--r-- | app/tests/test_cleaning.py | 61 | ||||
| -rw-r--r-- | app/tests/test_converter.py | 19 | ||||
| -rw-r--r-- | app/tests/test_extractors.py | 60 | ||||
| -rw-r--r-- | app/tests/test_hub.py | 46 | ||||
| -rwxr-xr-x | audiobook.py | 65 |
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%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: |
