aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/converter/converter.py3
-rw-r--r--app/logging_kit.py18
-rw-r--r--app/tests/test_audiobook_cli.py34
-rw-r--r--app/tests/test_runview.py29
-rw-r--r--app/ui/hub.py3
-rw-r--r--app/ui/runview.py7
-rwxr-xr-xaudiobook.py18
7 files changed, 97 insertions, 15 deletions
diff --git a/app/converter/converter.py b/app/converter/converter.py
index a9ea7eb..bd5e477 100644
--- a/app/converter/converter.py
+++ b/app/converter/converter.py
@@ -8,7 +8,6 @@ import sys
import threading
import time
from collections import Counter
-from datetime import datetime
from pathlib import Path
from typing import Callable, Dict, List, Optional, Tuple
@@ -89,7 +88,7 @@ def setup_logging(debug: bool = False, console: bool = True) -> None:
"""
LOGS_FOLDER.mkdir(parents=True, exist_ok=True)
file_handler = logging.FileHandler(
- LOGS_FOLDER / f"audiobook_{datetime.now():%Y%m%d}.log",
+ logging_kit.stream_path("audiobook", LOGS_FOLDER),
encoding="utf-8",
)
file_handler.setLevel(logging.DEBUG if debug else logging.INFO)
diff --git a/app/logging_kit.py b/app/logging_kit.py
index 0fe39c2..8950e6a 100644
--- a/app/logging_kit.py
+++ b/app/logging_kit.py
@@ -50,17 +50,27 @@ def log_traceback() -> None:
RETENTION_DAYS = 30
+def stream_path(prefix: str, log_dir: Path = None) -> Path:
+ """The ``<prefix>_YYYYMMDD.log`` stream path, without opening it.
+
+ The one place the stream naming convention is spelled out; callers
+ point users at the file (e.g. a failure's full details) without
+ creating or opening it.
+ """
+ directory = log_dir if log_dir is not None else LOG_DIR
+ return directory / f"{prefix}_{datetime.now():%Y%m%d}.log"
+
+
def day_stream(prefix: str, log_dir: Path = None):
"""Open today's ``<prefix>_YYYYMMDD.log`` stream for appending.
Returns the open text handle (write through write_line so lines are
flushed), or None when the directory/file cannot be opened.
"""
- directory = log_dir if log_dir is not None else LOG_DIR
+ path = stream_path(prefix, log_dir)
try:
- directory.mkdir(parents=True, exist_ok=True)
- return (directory / f"{prefix}_{datetime.now():%Y%m%d}.log"
- ).open("a", encoding="utf-8")
+ path.parent.mkdir(parents=True, exist_ok=True)
+ return path.open("a", encoding="utf-8")
except OSError:
return None
diff --git a/app/tests/test_audiobook_cli.py b/app/tests/test_audiobook_cli.py
index f13cf9e..a3f0b90 100644
--- a/app/tests/test_audiobook_cli.py
+++ b/app/tests/test_audiobook_cli.py
@@ -26,6 +26,7 @@ if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
import audiobook # noqa: E402
+import logging_kit # noqa: E402
from converter import config # noqa: E402
from converter import converter as converter_mod # noqa: E402
from converter.converter import AudiobookConverter # noqa: E402
@@ -565,6 +566,10 @@ class FatalErrorReportingTests(unittest.TestCase):
(log_path,) = self.log_dir.glob("audiobook_*.log")
return log_path.read_text(encoding="utf-8")
+ def _log_path(self) -> str:
+ """The dated log file's full path, as the console reports it."""
+ return str(logging_kit.stream_path("audiobook", self.log_dir))
+
def test_expected_failure_shows_one_friendly_line(self):
message = ("The audio.cpp model 'Qwen3-TTS-12Hz-1.7B-Base-GGUF' "
"(family 'qwen3_tts') has no built-in speakers "
@@ -573,6 +578,8 @@ class FatalErrorReportingTests(unittest.TestCase):
self.assertEqual(code, 1)
self.assertEqual(out.count("[FATAL]"), 1)
self.assertIn(f"[FATAL] Fatal error: {message}", out)
+ self.assertIn(f"[INFO] Full details in the log file: "
+ f"{self._log_path()}", out)
self.assertNotIn("Traceback (most recent call last)", out)
self.assertNotIn("Traceback (most recent call last)", err)
log_text = self._log_text()
@@ -581,11 +588,14 @@ class FatalErrorReportingTests(unittest.TestCase):
def test_expected_failure_reports_error_event(self):
events = []
- code, _, _ = self._convert(ValueError("bad input"),
- progress=events.append)
+ code, out, _ = self._convert(ValueError("bad input"),
+ progress=events.append)
self.assertEqual(code, 1)
self.assertEqual(events,
[{"kind": "error", "message": "bad input"}])
+ # The TUI run view owns the console and points failures at the log
+ # itself; no console pointer on this path.
+ self.assertNotIn("Full details in the log file", out)
def test_unexpected_crash_also_shows_traceback(self):
code, out, err = self._convert(TypeError("boom"))
@@ -594,6 +604,26 @@ class FatalErrorReportingTests(unittest.TestCase):
self.assertIn("Traceback (most recent call last)", err)
self.assertIn("TypeError: boom", err)
self.assertIn("Traceback (most recent call last)", self._log_text())
+ self.assertIn(f"[INFO] Full details in the log file: "
+ f"{self._log_path()}", out)
+
+ def test_failed_run_prints_the_log_path(self):
+ # A run that fails without raising (a book aborted the rest) ends
+ # with the log file path too.
+ preflight = MagicMock(
+ return_value=([self.book], [(self.book, "dune")]))
+ fake_class = MagicMock()
+ fake_class.preflight_overwrites = preflight
+ fake_class.return_value.run.return_value = False
+ out, err = io.StringIO(), io.StringIO()
+ with patch.object(audiobook, "setup_directories"), \
+ contextlib.redirect_stdout(out), \
+ contextlib.redirect_stderr(err), \
+ patch.object(audiobook, "AudiobookConverter", fake_class):
+ code = audiobook.convert(backend="audiocpp")
+ self.assertEqual(code, 1)
+ self.assertIn(f"[INFO] Full details in the log file: "
+ f"{self._log_path()}", out.getvalue())
if __name__ == "__main__":
diff --git a/app/tests/test_runview.py b/app/tests/test_runview.py
index 3f77ed0..5c0dc38 100644
--- a/app/tests/test_runview.py
+++ b/app/tests/test_runview.py
@@ -333,13 +333,15 @@ class RunLoopTests(_FakeTui, unittest.TestCase):
def test_stop_and_exit_stops_server_and_quits_without_keys(self):
# Toggle on: the moment the run ends the server is stopped and the
- # view reports quit — no key press and no prompt anywhere.
+ # view reports quit — no key press and no prompt anywhere. A
+ # successful run's summary does not point at the log file.
with patch.object(runview.servers, "alive", return_value=True), \
patch.object(runview.servers, "stop") as mk_stop, \
patch.object(runview.common,
"record_post_tui_notice") as mk_notice:
view, screen = self.make_view([], autostart_spec="SPEC",
- stop_and_exit=True)
+ stop_and_exit=True,
+ log_path="/tmp/runs/a.log")
view.started_server = True
view._queue.put({"kind": "book", "index": 1, "total": 1,
"name": "book.txt"})
@@ -355,6 +357,7 @@ class RunLoopTests(_FakeTui, unittest.TestCase):
self.assertIn("[OK] book.txt: book_test_michael.mp3", text)
self.assertIn("1 of 1 book(s) generated successfully", text)
self.assertIn("Elapsed time:", text)
+ self.assertNotIn("Full details in the log file", text)
def test_stop_and_exit_leaves_external_servers_alone(self):
# A server this run did not start is never stopped; the TUI still
@@ -374,13 +377,15 @@ class RunLoopTests(_FakeTui, unittest.TestCase):
def test_stop_and_exit_failure_summary_lists_the_error(self):
# A failed ending also auto-exits; the failing book's detail line
- # lands in the post-TUI summary.
+ # lands in the post-TUI summary, which ends with the converter's
+ # log file path where the full details live.
with patch.object(runview.servers, "alive", return_value=True), \
patch.object(runview.servers, "stop"), \
patch.object(runview.common,
"record_post_tui_notice") as mk_notice:
view, screen = self.make_view([], autostart_spec="SPEC",
- stop_and_exit=True)
+ stop_and_exit=True,
+ log_path="/tmp/runs/a.log")
view.started_server = True
view._queue.put({"kind": "book_failed", "name": "bad.txt",
"error": "chunk 3 failed",
@@ -392,6 +397,22 @@ class RunLoopTests(_FakeTui, unittest.TestCase):
self.assertIn("[FAIL] bad.txt: bad_x.mp3", text)
self.assertIn("chunk 3 failed", text)
self.assertIn("0 of 1 book(s) generated successfully", text)
+ self.assertIn("Full details in the log file: /tmp/runs/a.log", text)
+
+ def test_stop_and_exit_empty_failure_summary_lists_the_log(self):
+ # A run that errors before any book result (worker crash) still
+ # points the post-TUI summary at the log file.
+ with patch.object(runview.servers, "stop"), \
+ patch.object(runview.common,
+ "record_post_tui_notice") as mk_notice:
+ view, screen = self.make_view([], stop_and_exit=True,
+ log_path="/tmp/runs/a.log")
+ view._queue.put({"kind": "error", "message": "boom"})
+ view.run()
+ self.assertIn("No books were converted",
+ mk_notice.call_args[0][0])
+ self.assertIn("Full details in the log file: /tmp/runs/a.log",
+ mk_notice.call_args[0][0])
class WorkerTests(_FakeTui, unittest.TestCase):
diff --git a/app/ui/hub.py b/app/ui/hub.py
index ef41d0b..fa05685 100644
--- a/app/ui/hub.py
+++ b/app/ui/hub.py
@@ -26,7 +26,6 @@ import json
import shutil
import sys
import urllib.parse
-from datetime import datetime
from pathlib import Path
from typing import Callable, Optional, Tuple
@@ -1803,7 +1802,7 @@ def _prepare_run_config(backend: str, kwargs: dict
info = get(backend)
if info is not None:
label = info.label
- log_path = str(LOGS_FOLDER / f"audiobook_{datetime.now():%Y%m%d}.log")
+ log_path = str(logging_kit.stream_path("audiobook", LOGS_FOLDER))
# The run view points failures at this file, so make sure it exists
# from the moment a run starts — even when the run dies before the
# converter's setup_logging creates it.
diff --git a/app/ui/runview.py b/app/ui/runview.py
index b3a1ab0..19a8f64 100644
--- a/app/ui/runview.py
+++ b/app/ui/runview.py
@@ -479,7 +479,8 @@ class RunView(ScreenView):
Output directory, one line per book with its generated file names
and OK/FAIL status (plus the failure detail), a success count, and
- the total elapsed time.
+ the total elapsed time. A failed run ends with the converter's log
+ file path, where the details behind the [FAIL] lines live.
"""
from converter.converter import AUDIOBOOKS_FOLDER
lines = ["Audiobook generation finished",
@@ -501,6 +502,10 @@ class RunView(ScreenView):
finished = self.finished_at or self._now()
elapsed = finished - (started if started is not None else finished)
lines.append(f"Elapsed time: {_format_elapsed(elapsed)}")
+ if (self.phase == "error" or ok_count < total) \
+ and self.config.log_path:
+ lines.append(f"Full details in the log file: "
+ f"{self.config.log_path}")
return "\n".join(lines)
_server_stopped_confirmed = False
diff --git a/audiobook.py b/audiobook.py
index 36fc2df..64ec0d6 100755
--- a/audiobook.py
+++ b/audiobook.py
@@ -80,6 +80,16 @@ def resolve_book_path(path: Path) -> Path:
return resolved if resolved.is_absolute() else _BASE_DIR / resolved
+def run_log_path() -> Path:
+ """The full path of this run's dated log file (app/logs/audiobook_*.log).
+
+ Pointed at from the console when a conversion run fails: the file
+ keeps the full record (the failure's traceback, per-chunk errors)
+ that the console summary only sketches.
+ """
+ return logging_kit.stream_path("audiobook", _converter_mod.LOGS_FOLDER)
+
+
def convert(backend: str, voice: str = None, clone: str = None,
transcription: str = None, no_transcription: bool = False,
language: str = None, speed: float = None, single_file: bool = False,
@@ -222,6 +232,10 @@ def convert(backend: str, voice: str = None, clone: str = None,
# Anything else is an actual crash, so also show the traceback.
logging_kit.log_traceback()
print(f"[FATAL] Fatal error: {exc}")
+ if progress is None:
+ # The run view owns the TUI console and points failures at the
+ # log itself (the error screen's details hint).
+ print(f"[INFO] Full details in the log file: {run_log_path()}")
if not isinstance(exc, (RuntimeError, ValueError)):
traceback.print_exc()
if progress is not None:
@@ -230,6 +244,10 @@ def convert(backend: str, voice: str = None, clone: str = None,
finally:
if server is not None:
server.shutdown()
+ if not ok and progress is None:
+ # The run failed (a book aborted the rest): the summary above only
+ # names the failed books, so point at the log with the details.
+ print(f"[INFO] Full details in the log file: {run_log_path()}")
return 0 if ok else 1