localcaption 0.2.0__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. {localcaption-0.2.0 → localcaption-0.3.0}/CHANGELOG.md +15 -0
  2. {localcaption-0.2.0 → localcaption-0.3.0}/PKG-INFO +10 -6
  3. {localcaption-0.2.0 → localcaption-0.3.0}/README.md +8 -4
  4. localcaption-0.3.0/docs/diagrams/architecture.png +0 -0
  5. localcaption-0.3.0/docs/diagrams/pipeline.png +0 -0
  6. localcaption-0.3.0/docs/diagrams/sequence.png +0 -0
  7. {localcaption-0.2.0 → localcaption-0.3.0}/pyproject.toml +1 -1
  8. {localcaption-0.2.0 → localcaption-0.3.0}/src/localcaption/cli.py +11 -8
  9. {localcaption-0.2.0 → localcaption-0.3.0}/src/localcaption/pipeline.py +12 -2
  10. localcaption-0.3.0/tests/test_cli_transcribe.py +66 -0
  11. localcaption-0.3.0/tests/test_pipeline.py +215 -0
  12. localcaption-0.2.0/docs/diagrams/architecture.png +0 -0
  13. localcaption-0.2.0/docs/diagrams/pipeline.png +0 -0
  14. localcaption-0.2.0/docs/diagrams/sequence.png +0 -0
  15. {localcaption-0.2.0 → localcaption-0.3.0}/.github/FUNDING.yml +0 -0
  16. {localcaption-0.2.0 → localcaption-0.3.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  17. {localcaption-0.2.0 → localcaption-0.3.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  18. {localcaption-0.2.0 → localcaption-0.3.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  19. {localcaption-0.2.0 → localcaption-0.3.0}/.github/pull_request_template.md +0 -0
  20. {localcaption-0.2.0 → localcaption-0.3.0}/.github/workflows/ci.yml +0 -0
  21. {localcaption-0.2.0 → localcaption-0.3.0}/.github/workflows/release.yml +0 -0
  22. {localcaption-0.2.0 → localcaption-0.3.0}/.gitignore +0 -0
  23. {localcaption-0.2.0 → localcaption-0.3.0}/CODE_OF_CONDUCT.md +0 -0
  24. {localcaption-0.2.0 → localcaption-0.3.0}/CONTRIBUTING.md +0 -0
  25. {localcaption-0.2.0 → localcaption-0.3.0}/LICENSE +0 -0
  26. {localcaption-0.2.0 → localcaption-0.3.0}/SECURITY.md +0 -0
  27. {localcaption-0.2.0 → localcaption-0.3.0}/docs/RELEASING.md +0 -0
  28. {localcaption-0.2.0 → localcaption-0.3.0}/docs/diagrams/architecture.mmd +0 -0
  29. {localcaption-0.2.0 → localcaption-0.3.0}/docs/diagrams/pipeline.mmd +0 -0
  30. {localcaption-0.2.0 → localcaption-0.3.0}/docs/diagrams/sequence.mmd +0 -0
  31. {localcaption-0.2.0 → localcaption-0.3.0}/scripts/install.sh +0 -0
  32. {localcaption-0.2.0 → localcaption-0.3.0}/scripts/setup.sh +0 -0
  33. {localcaption-0.2.0 → localcaption-0.3.0}/scripts/uninstall.sh +0 -0
  34. {localcaption-0.2.0 → localcaption-0.3.0}/src/localcaption/__init__.py +0 -0
  35. {localcaption-0.2.0 → localcaption-0.3.0}/src/localcaption/__main__.py +0 -0
  36. {localcaption-0.2.0 → localcaption-0.3.0}/src/localcaption/_logging.py +0 -0
  37. {localcaption-0.2.0 → localcaption-0.3.0}/src/localcaption/audio.py +0 -0
  38. {localcaption-0.2.0 → localcaption-0.3.0}/src/localcaption/download.py +0 -0
  39. {localcaption-0.2.0 → localcaption-0.3.0}/src/localcaption/errors.py +0 -0
  40. {localcaption-0.2.0 → localcaption-0.3.0}/src/localcaption/installer.py +0 -0
  41. {localcaption-0.2.0 → localcaption-0.3.0}/src/localcaption/models.py +0 -0
  42. {localcaption-0.2.0 → localcaption-0.3.0}/src/localcaption/whisper.py +0 -0
  43. {localcaption-0.2.0 → localcaption-0.3.0}/tests/__init__.py +0 -0
  44. {localcaption-0.2.0 → localcaption-0.3.0}/tests/test_cli_doctor.py +0 -0
  45. {localcaption-0.2.0 → localcaption-0.3.0}/tests/test_cli_model.py +0 -0
  46. {localcaption-0.2.0 → localcaption-0.3.0}/tests/test_imports.py +0 -0
  47. {localcaption-0.2.0 → localcaption-0.3.0}/tests/test_installer.py +0 -0
  48. {localcaption-0.2.0 → localcaption-0.3.0}/tests/test_models.py +0 -0
  49. {localcaption-0.2.0 → localcaption-0.3.0}/tests/test_models_download.py +0 -0
  50. {localcaption-0.2.0 → localcaption-0.3.0}/tests/test_whisper_paths.py +0 -0
@@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.0] - 2026-08-16
11
+
12
+ ### Added
13
+ - **Local video and audio files.** `localcaption /path/to/video.mp4` (or a
14
+ wav / mp3 / mkv / similar) runs the same ffmpeg + whisper.cpp path
15
+ without calling yt-dlp. Output names come from the input filename.
16
+
17
+ ### Changed
18
+ - CLI help, `doctor`, and the README now say `<url-or-file>` instead of
19
+ treating this as URL-only.
20
+
21
+ ### Fixed
22
+ - Architecture, pipeline, and sequence diagrams render with a white
23
+ background so they stay readable on GitHub dark mode.
24
+
10
25
  ## [0.2.0] - 2026-06-06
11
26
 
12
27
  ### Added
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: localcaption
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Fully-local video → transcript pipeline using yt-dlp, ffmpeg, and whisper.cpp. Supports YouTube, Vimeo, Twitch, and 1000+ sites. No API keys.
5
5
  Project-URL: Homepage, https://github.com/jatinkrmalik/localcaption
6
6
  Project-URL: Repository, https://github.com/jatinkrmalik/localcaption
@@ -219,6 +219,10 @@ localcaption "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
219
219
 
220
220
  # Vimeo, Twitch, Twitter/X, and 1000+ other sites work too
221
221
  localcaption "https://vimeo.com/148751763"
222
+
223
+ # Local video/audio files
224
+ localcaption /path/to/video.mp4
225
+ localcaption ./recording.wav
222
226
  ```
223
227
 
224
228
  | flag | default | what it does |
@@ -236,15 +240,15 @@ localcaption "https://vimeo.com/148751763"
236
240
  3. `./whisper.cpp` (dev checkout).
237
241
  4. `~/.local/share/localcaption/whisper.cpp` (where `install.sh` puts it).
238
242
 
239
- Outputs `<videoId>.txt`, `.srt`, `.vtt`, and `.json` in the chosen directory.
243
+ Outputs `<videoId>.txt`, `.srt`, `.vtt`, and `.json` in the chosen directory. For local files, the output filename is derived from the input file's name.
240
244
 
241
- You can also invoke it as a module: `python -m localcaption <url>`.
245
+ You can also invoke it as a module: `python -m localcaption <url-or-file>`.
242
246
 
243
247
  ### Subcommands
244
248
 
245
249
  | Subcommand | What it does |
246
250
  |---|---|
247
- | _(default)_ `localcaption <url>` | Transcribe a single URL. |
251
+ | _(default)_ `localcaption <url-or-file>` | Transcribe a URL or local video/audio file. |
248
252
  | `localcaption doctor` | Read-only diagnostic: prereqs, whisper.cpp, available models. Useful before filing a bug. |
249
253
  | `localcaption doctor --fix` | Self-heal: install missing system deps, clone+build whisper.cpp, download the default model, then re-verify. Idempotent. |
250
254
  | `localcaption model list` | List every supported whisper model with size + install status. |
@@ -339,7 +343,7 @@ the subprocess hops to yt-dlp, ffmpeg, and whisper.cpp. The intermediate
339
343
  > files alongside the rendered PNGs. Regenerate with:
340
344
  > ```bash
341
345
  > mmdc -i docs/diagrams/<name>.mmd -o docs/diagrams/<name>.png \
342
- > -t default -b transparent --width 1600 --scale 2
346
+ > -t default -b white --width 1600 --scale 2
343
347
  > ```
344
348
 
345
349
  ## Benchmarks
@@ -164,6 +164,10 @@ localcaption "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
164
164
 
165
165
  # Vimeo, Twitch, Twitter/X, and 1000+ other sites work too
166
166
  localcaption "https://vimeo.com/148751763"
167
+
168
+ # Local video/audio files
169
+ localcaption /path/to/video.mp4
170
+ localcaption ./recording.wav
167
171
  ```
168
172
 
169
173
  | flag | default | what it does |
@@ -181,15 +185,15 @@ localcaption "https://vimeo.com/148751763"
181
185
  3. `./whisper.cpp` (dev checkout).
182
186
  4. `~/.local/share/localcaption/whisper.cpp` (where `install.sh` puts it).
183
187
 
184
- Outputs `<videoId>.txt`, `.srt`, `.vtt`, and `.json` in the chosen directory.
188
+ Outputs `<videoId>.txt`, `.srt`, `.vtt`, and `.json` in the chosen directory. For local files, the output filename is derived from the input file's name.
185
189
 
186
- You can also invoke it as a module: `python -m localcaption <url>`.
190
+ You can also invoke it as a module: `python -m localcaption <url-or-file>`.
187
191
 
188
192
  ### Subcommands
189
193
 
190
194
  | Subcommand | What it does |
191
195
  |---|---|
192
- | _(default)_ `localcaption <url>` | Transcribe a single URL. |
196
+ | _(default)_ `localcaption <url-or-file>` | Transcribe a URL or local video/audio file. |
193
197
  | `localcaption doctor` | Read-only diagnostic: prereqs, whisper.cpp, available models. Useful before filing a bug. |
194
198
  | `localcaption doctor --fix` | Self-heal: install missing system deps, clone+build whisper.cpp, download the default model, then re-verify. Idempotent. |
195
199
  | `localcaption model list` | List every supported whisper model with size + install status. |
@@ -284,7 +288,7 @@ the subprocess hops to yt-dlp, ffmpeg, and whisper.cpp. The intermediate
284
288
  > files alongside the rendered PNGs. Regenerate with:
285
289
  > ```bash
286
290
  > mmdc -i docs/diagrams/<name>.mmd -o docs/diagrams/<name>.png \
287
- > -t default -b transparent --width 1600 --scale 2
291
+ > -t default -b white --width 1600 --scale 2
288
292
  > ```
289
293
 
290
294
  ## Benchmarks
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "localcaption"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "Fully-local video → transcript pipeline using yt-dlp, ffmpeg, and whisper.cpp. Supports YouTube, Vimeo, Twitch, and 1000+ sites. No API keys."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -4,8 +4,8 @@ Exposed as the ``localcaption`` console script via ``pyproject.toml``.
4
4
 
5
5
  Two invocation styles are supported:
6
6
 
7
- localcaption <url> [options] # one-shot transcription (default)
8
- localcaption doctor # diagnose your install
7
+ localcaption <url-or-file> [options] # one-shot transcription (default)
8
+ localcaption doctor # diagnose your install
9
9
  """
10
10
 
11
11
  from __future__ import annotations
@@ -69,9 +69,12 @@ def _default_whisper_dir() -> Path:
69
69
  def _build_transcribe_parser() -> argparse.ArgumentParser:
70
70
  parser = argparse.ArgumentParser(
71
71
  prog="localcaption",
72
- description="Fully-local YouTube → transcript using yt-dlp + ffmpeg + whisper.cpp.",
72
+ description="Fully-local video → transcript using yt-dlp + ffmpeg + whisper.cpp.",
73
+ )
74
+ parser.add_argument(
75
+ "url",
76
+ help="YouTube URL, any URL yt-dlp supports, or path to a local video/audio file",
73
77
  )
74
- parser.add_argument("url", help="YouTube URL (or any URL yt-dlp supports)")
75
78
  parser.add_argument(
76
79
  "-m", "--model", default=DEFAULT_MODEL,
77
80
  help=f"whisper model name (default: {DEFAULT_MODEL})",
@@ -400,7 +403,7 @@ def _cmd_doctor(argv: list[str]) -> int:
400
403
  all_ok, fix_hints, gaps = _run_doctor_diagnostics(whisper_dir)
401
404
 
402
405
  if all_ok:
403
- print("\nAll checks passed. You're good to go: localcaption <url>")
406
+ print("\nAll checks passed. You're good to go: localcaption <url-or-file>")
404
407
  return 0
405
408
 
406
409
  if not args.fix:
@@ -422,7 +425,7 @@ def _cmd_doctor(argv: list[str]) -> int:
422
425
  print("Re-running diagnostics to verify…\n")
423
426
  all_ok_after, _, _ = _run_doctor_diagnostics(whisper_dir)
424
427
  if all_ok_after:
425
- print("\nAll checks passed. You're good to go: localcaption <url>")
428
+ print("\nAll checks passed. You're good to go: localcaption <url-or-file>")
426
429
  return 0
427
430
  print("\nSome checks still failing — see output above.")
428
431
  return 1
@@ -579,7 +582,7 @@ def _cmd_model_download(argv: list[str]) -> int:
579
582
  return 130
580
583
 
581
584
  print(f"✅ Done. {path}")
582
- print(f" Use it with: localcaption --model {spec.name} <url>")
585
+ print(f" Use it with: localcaption --model {spec.name} <url-or-file>")
583
586
  return 0
584
587
 
585
588
 
@@ -628,7 +631,7 @@ def _cmd_model_rm(argv: list[str]) -> int:
628
631
 
629
632
  def _print_top_level_help() -> None:
630
633
  print("""\
631
- usage: localcaption <url> [options] transcribe a video (default)
634
+ usage: localcaption <url-or-file> [options] transcribe a video (default)
632
635
  localcaption doctor diagnose your install
633
636
  localcaption model <subcommand> list / download / remove models
634
637
  localcaption --help show transcribe help
@@ -25,6 +25,12 @@ class PipelineResult:
25
25
  transcripts: TranscriptionResult
26
26
 
27
27
 
28
+ def _is_local_file(source: str) -> bool:
29
+ if "://" in source:
30
+ return False
31
+ return Path(source).is_file()
32
+
33
+
28
34
  def transcribe_url(
29
35
  url: str,
30
36
  *,
@@ -36,10 +42,12 @@ def transcribe_url(
36
42
  ) -> PipelineResult:
37
43
  """Run the full pipeline on *url* and return the produced artefacts.
38
44
 
45
+ *url* may be an actual URL or a path to a local video/audio file.
46
+
39
47
  Parameters
40
48
  ----------
41
49
  url:
42
- Any URL `yt-dlp` can resolve.
50
+ Any URL `yt-dlp` can resolve, or a local file path.
43
51
  out_dir:
44
52
  Directory for the final transcript files.
45
53
  whisper_dir:
@@ -59,7 +67,9 @@ def transcribe_url(
59
67
  audio_path: Path | None = None
60
68
  wav_path: Path | None = None
61
69
  try:
62
- audio_path = download_audio(url, work_dir)
70
+ audio_path = (
71
+ Path(url).resolve() if _is_local_file(url) else download_audio(url, work_dir)
72
+ )
63
73
  wav_path = work_dir / f"{audio_path.stem}.16k.wav"
64
74
  to_whisper_wav(audio_path, wav_path)
65
75
 
@@ -0,0 +1,66 @@
1
+ """Tests for the default transcribe subcommand and dispatcher."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import pytest
8
+
9
+ from localcaption.cli import main
10
+
11
+
12
+ class TestCliHelpText:
13
+ def test_transcribe_help_mentions_local_file(self, capsys) -> None:
14
+ with pytest.raises(SystemExit) as excinfo:
15
+ main(["--help"])
16
+ assert excinfo.value.code == 0
17
+ out = capsys.readouterr().out
18
+ assert "local video/audio file" in out
19
+
20
+ def test_top_level_help_mentions_url_or_file(self, capsys) -> None:
21
+ rc = main([])
22
+ assert rc == 2
23
+ out = capsys.readouterr().out
24
+ assert "url-or-file" in out
25
+
26
+
27
+ class TestCliLocalFileDispatch:
28
+ def test_local_file_path_passed_to_pipeline(self, monkeypatch, tmp_path: Path) -> None:
29
+ video = tmp_path / "my_video.mp4"
30
+ video.write_text("fake")
31
+ sentinel: dict[str, str] = {}
32
+
33
+ def fake_transcribe_url(url, **kw):
34
+ sentinel["url"] = url
35
+ raise SystemExit(0)
36
+
37
+ monkeypatch.setattr("localcaption.cli.transcribe_url", fake_transcribe_url)
38
+ with pytest.raises(SystemExit):
39
+ main([str(video)])
40
+ assert sentinel["url"] == str(video)
41
+
42
+ def test_url_still_passed_to_pipeline(self, monkeypatch) -> None:
43
+ sentinel: dict[str, str] = {}
44
+
45
+ def fake_transcribe_url(url, **kw):
46
+ sentinel["url"] = url
47
+ raise SystemExit(0)
48
+
49
+ monkeypatch.setattr("localcaption.cli.transcribe_url", fake_transcribe_url)
50
+ with pytest.raises(SystemExit):
51
+ main(["https://www.youtube.com/watch?v=dQw4w9WgXcQ"])
52
+ assert sentinel["url"] == "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
53
+
54
+ def test_relative_local_file_passed_to_pipeline(self, monkeypatch, tmp_path: Path) -> None:
55
+ video = tmp_path / "relative.mp4"
56
+ video.write_text("fake")
57
+ sentinel: dict[str, str] = {}
58
+
59
+ def fake_transcribe_url(url, **kw):
60
+ sentinel["url"] = url
61
+ raise SystemExit(0)
62
+
63
+ monkeypatch.setattr("localcaption.cli.transcribe_url", fake_transcribe_url)
64
+ with pytest.raises(SystemExit):
65
+ main(["./relative.mp4"])
66
+ assert sentinel["url"] == "./relative.mp4"
@@ -0,0 +1,215 @@
1
+ """Tests for the pipeline orchestration layer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from unittest.mock import MagicMock
7
+
8
+ from localcaption.pipeline import PipelineResult, _is_local_file, transcribe_url
9
+
10
+
11
+ class TestIsLocalFile:
12
+ def test_existing_file_returns_true(self, tmp_path: Path) -> None:
13
+ video = tmp_path / "video.mp4"
14
+ video.write_text("fake")
15
+ assert _is_local_file(str(video)) is True
16
+
17
+ def test_relative_existing_file_returns_true(self, tmp_path: Path) -> None:
18
+ video = tmp_path / "video.mp4"
19
+ video.write_text("fake")
20
+ import os
21
+ old_cwd = os.getcwd()
22
+ try:
23
+ os.chdir(tmp_path)
24
+ assert _is_local_file("./video.mp4") is True
25
+ assert _is_local_file("video.mp4") is True
26
+ finally:
27
+ os.chdir(old_cwd)
28
+
29
+ def test_url_returns_false(self) -> None:
30
+ assert _is_local_file("https://www.youtube.com/watch?v=dQw4w9WgXcQ") is False
31
+ assert _is_local_file("http://example.com/video.mp4") is False
32
+
33
+ def test_nonexistent_path_returns_false(self, tmp_path: Path) -> None:
34
+ assert _is_local_file(str(tmp_path / "does_not_exist.mp4")) is False
35
+
36
+ def test_file_url_returns_false(self, tmp_path: Path) -> None:
37
+ assert _is_local_file(f"file://{tmp_path}/video.mp4") is False
38
+
39
+
40
+ class TestTranscribeUrlLocalFile:
41
+ def test_local_file_skips_download(
42
+ self, monkeypatch, tmp_path: Path
43
+ ) -> None:
44
+ video = tmp_path / "my_video.mp4"
45
+ video.write_text("fake video")
46
+ out_dir = tmp_path / "out"
47
+ whisper_dir = tmp_path / "whisper.cpp"
48
+
49
+ download_called = False
50
+
51
+ def fake_download(url, work_dir):
52
+ nonlocal download_called
53
+ download_called = True
54
+ return work_dir / "downloaded.mp4"
55
+
56
+ def fake_to_whisper_wav(src, dst):
57
+ dst.write_text("fake wav")
58
+ return dst
59
+
60
+ fake_transcripts = MagicMock()
61
+ fake_transcripts.existing.return_value = {}
62
+
63
+ def fake_transcribe(wav, model, out_base, *, whisper_dir, language):
64
+ return fake_transcripts
65
+
66
+ monkeypatch.setattr("localcaption.pipeline.download_audio", fake_download)
67
+ monkeypatch.setattr("localcaption.pipeline.to_whisper_wav", fake_to_whisper_wav)
68
+ monkeypatch.setattr("localcaption.pipeline.transcribe", fake_transcribe)
69
+
70
+ result = transcribe_url(
71
+ str(video),
72
+ out_dir=out_dir,
73
+ whisper_dir=whisper_dir,
74
+ model="base.en",
75
+ keep_intermediate=True,
76
+ )
77
+
78
+ assert download_called is False
79
+ assert isinstance(result, PipelineResult)
80
+ assert result.source_url == str(video)
81
+ assert result.audio_path == video.resolve()
82
+
83
+ def test_local_file_uses_correct_stem_for_output(
84
+ self, monkeypatch, tmp_path: Path
85
+ ) -> None:
86
+ video = tmp_path / "interview.mkv"
87
+ video.write_text("fake video")
88
+ out_dir = tmp_path / "out"
89
+ whisper_dir = tmp_path / "whisper.cpp"
90
+
91
+ captured = {}
92
+
93
+ def fake_to_whisper_wav(src, dst):
94
+ dst.write_text("fake wav")
95
+ return dst
96
+
97
+ def fake_transcribe(wav, model, out_base, *, whisper_dir, language):
98
+ captured["out_base"] = out_base
99
+ fake_transcripts = MagicMock()
100
+ fake_transcripts.existing.return_value = {}
101
+ return fake_transcripts
102
+
103
+ monkeypatch.setattr("localcaption.pipeline.to_whisper_wav", fake_to_whisper_wav)
104
+ monkeypatch.setattr("localcaption.pipeline.transcribe", fake_transcribe)
105
+
106
+ transcribe_url(
107
+ str(video),
108
+ out_dir=out_dir,
109
+ whisper_dir=whisper_dir,
110
+ model="base.en",
111
+ )
112
+
113
+ assert captured["out_base"] == out_dir / "interview"
114
+
115
+ def test_url_still_calls_download(
116
+ self, monkeypatch, tmp_path: Path
117
+ ) -> None:
118
+ out_dir = tmp_path / "out"
119
+ whisper_dir = tmp_path / "whisper.cpp"
120
+ work_dir = out_dir / ".work"
121
+ work_dir.mkdir(parents=True)
122
+
123
+ def fake_download(url, work_dir):
124
+ downloaded = work_dir / "yt_video.m4a"
125
+ downloaded.write_text("fake audio")
126
+ return downloaded
127
+
128
+ def fake_to_whisper_wav(src, dst):
129
+ dst.write_text("fake wav")
130
+ return dst
131
+
132
+ fake_transcripts = MagicMock()
133
+ fake_transcripts.existing.return_value = {}
134
+
135
+ def fake_transcribe(wav, model, out_base, *, whisper_dir, language):
136
+ return fake_transcripts
137
+
138
+ monkeypatch.setattr("localcaption.pipeline.download_audio", fake_download)
139
+ monkeypatch.setattr("localcaption.pipeline.to_whisper_wav", fake_to_whisper_wav)
140
+ monkeypatch.setattr("localcaption.pipeline.transcribe", fake_transcribe)
141
+
142
+ result = transcribe_url(
143
+ "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
144
+ out_dir=out_dir,
145
+ whisper_dir=whisper_dir,
146
+ model="base.en",
147
+ )
148
+
149
+ assert isinstance(result, PipelineResult)
150
+ assert result.source_url == "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
151
+
152
+ def test_keep_intermediate_preserves_local_audio_path(
153
+ self, monkeypatch, tmp_path: Path
154
+ ) -> None:
155
+ video = tmp_path / "podcast.mp3"
156
+ video.write_text("fake audio")
157
+ out_dir = tmp_path / "out"
158
+ whisper_dir = tmp_path / "whisper.cpp"
159
+
160
+ def fake_to_whisper_wav(src, dst):
161
+ dst.write_text("fake wav")
162
+ return dst
163
+
164
+ fake_transcripts = MagicMock()
165
+ fake_transcripts.existing.return_value = {}
166
+
167
+ def fake_transcribe(wav, model, out_base, *, whisper_dir, language):
168
+ return fake_transcripts
169
+
170
+ monkeypatch.setattr("localcaption.pipeline.to_whisper_wav", fake_to_whisper_wav)
171
+ monkeypatch.setattr("localcaption.pipeline.transcribe", fake_transcribe)
172
+
173
+ result = transcribe_url(
174
+ str(video),
175
+ out_dir=out_dir,
176
+ whisper_dir=whisper_dir,
177
+ model="base.en",
178
+ keep_intermediate=True,
179
+ )
180
+
181
+ assert result.audio_path == video.resolve()
182
+ assert result.wav_path is not None
183
+ assert result.wav_path.name == "podcast.16k.wav"
184
+
185
+ def test_no_keep_intermediate_clears_paths(
186
+ self, monkeypatch, tmp_path: Path
187
+ ) -> None:
188
+ video = tmp_path / "podcast.mp3"
189
+ video.write_text("fake audio")
190
+ out_dir = tmp_path / "out"
191
+ whisper_dir = tmp_path / "whisper.cpp"
192
+
193
+ def fake_to_whisper_wav(src, dst):
194
+ dst.write_text("fake wav")
195
+ return dst
196
+
197
+ fake_transcripts = MagicMock()
198
+ fake_transcripts.existing.return_value = {}
199
+
200
+ def fake_transcribe(wav, model, out_base, *, whisper_dir, language):
201
+ return fake_transcripts
202
+
203
+ monkeypatch.setattr("localcaption.pipeline.to_whisper_wav", fake_to_whisper_wav)
204
+ monkeypatch.setattr("localcaption.pipeline.transcribe", fake_transcribe)
205
+
206
+ result = transcribe_url(
207
+ str(video),
208
+ out_dir=out_dir,
209
+ whisper_dir=whisper_dir,
210
+ model="base.en",
211
+ keep_intermediate=False,
212
+ )
213
+
214
+ assert result.audio_path is None
215
+ assert result.wav_path is None
File without changes
File without changes
File without changes