fow-cli 0.1.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 (51) hide show
  1. fow_cli-0.3.0/CHANGELOG.md +33 -0
  2. {fow_cli-0.1.0 → fow_cli-0.3.0}/PKG-INFO +37 -2
  3. {fow_cli-0.1.0 → fow_cli-0.3.0}/README.md +36 -1
  4. {fow_cli-0.1.0 → fow_cli-0.3.0}/pyproject.toml +25 -1
  5. {fow_cli-0.1.0 → fow_cli-0.3.0}/src/fly_on_the_wall/__init__.py +1 -1
  6. fow_cli-0.3.0/src/fly_on_the_wall/api_keys.py +6 -0
  7. {fow_cli-0.1.0 → fow_cli-0.3.0}/src/fly_on_the_wall/audio_metadata.py +19 -6
  8. {fow_cli-0.1.0 → fow_cli-0.3.0}/src/fly_on_the_wall/cli.py +11 -7
  9. fow_cli-0.3.0/src/fly_on_the_wall/cli_glossary.py +124 -0
  10. {fow_cli-0.1.0 → fow_cli-0.3.0}/src/fly_on_the_wall/cli_menu.py +28 -20
  11. {fow_cli-0.1.0 → fow_cli-0.3.0}/src/fly_on_the_wall/cli_watch.py +38 -2
  12. {fow_cli-0.1.0 → fow_cli-0.3.0}/src/fly_on_the_wall/config.py +0 -5
  13. {fow_cli-0.1.0 → fow_cli-0.3.0}/src/fly_on_the_wall/db.py +20 -3
  14. {fow_cli-0.1.0 → fow_cli-0.3.0}/src/fly_on_the_wall/embeddings.py +26 -5
  15. fow_cli-0.3.0/src/fly_on_the_wall/glossary.py +207 -0
  16. {fow_cli-0.1.0 → fow_cli-0.3.0}/src/fly_on_the_wall/processing.py +46 -26
  17. {fow_cli-0.1.0 → fow_cli-0.3.0}/src/fly_on_the_wall/providers/elevenlabs.py +19 -4
  18. {fow_cli-0.1.0 → fow_cli-0.3.0}/src/fly_on_the_wall/providers/openai_analysis.py +27 -8
  19. {fow_cli-0.1.0 → fow_cli-0.3.0}/src/fly_on_the_wall/providers/openai_cleanup.py +15 -5
  20. {fow_cli-0.1.0 → fow_cli-0.3.0}/src/fly_on_the_wall/publishing.py +32 -3
  21. fow_cli-0.3.0/src/fly_on_the_wall/py.typed +0 -0
  22. {fow_cli-0.1.0 → fow_cli-0.3.0}/src/fly_on_the_wall/secrets.py +3 -3
  23. {fow_cli-0.1.0 → fow_cli-0.3.0}/src/fly_on_the_wall/setup.py +4 -2
  24. {fow_cli-0.1.0 → fow_cli-0.3.0}/src/fly_on_the_wall/speaker_matching.py +9 -2
  25. {fow_cli-0.1.0 → fow_cli-0.3.0}/src/fly_on_the_wall/watch.py +57 -11
  26. fow_cli-0.1.0/src/fly_on_the_wall/glossary.py +0 -31
  27. {fow_cli-0.1.0 → fow_cli-0.3.0}/.gitignore +0 -0
  28. {fow_cli-0.1.0 → fow_cli-0.3.0}/LICENSE +0 -0
  29. {fow_cli-0.1.0 → fow_cli-0.3.0}/src/fly_on_the_wall/audio.py +0 -0
  30. {fow_cli-0.1.0 → fow_cli-0.3.0}/src/fly_on_the_wall/cache.py +0 -0
  31. {fow_cli-0.1.0 → fow_cli-0.3.0}/src/fly_on_the_wall/cleanup.py +0 -0
  32. {fow_cli-0.1.0 → fow_cli-0.3.0}/src/fly_on_the_wall/cli_costs.py +0 -0
  33. {fow_cli-0.1.0 → fow_cli-0.3.0}/src/fly_on_the_wall/cli_publish.py +0 -0
  34. {fow_cli-0.1.0 → fow_cli-0.3.0}/src/fly_on_the_wall/cli_speaker_review.py +0 -0
  35. {fow_cli-0.1.0 → fow_cli-0.3.0}/src/fly_on_the_wall/costs.py +0 -0
  36. {fow_cli-0.1.0 → fow_cli-0.3.0}/src/fly_on_the_wall/doctor.py +0 -0
  37. {fow_cli-0.1.0 → fow_cli-0.3.0}/src/fly_on_the_wall/exporting.py +0 -0
  38. {fow_cli-0.1.0 → fow_cli-0.3.0}/src/fly_on_the_wall/meetings.py +0 -0
  39. {fow_cli-0.1.0 → fow_cli-0.3.0}/src/fly_on_the_wall/normalization.py +0 -0
  40. {fow_cli-0.1.0 → fow_cli-0.3.0}/src/fly_on_the_wall/people.py +0 -0
  41. {fow_cli-0.1.0 → fow_cli-0.3.0}/src/fly_on_the_wall/people_embeddings.py +0 -0
  42. {fow_cli-0.1.0 → fow_cli-0.3.0}/src/fly_on_the_wall/pipeline.py +0 -0
  43. {fow_cli-0.1.0 → fow_cli-0.3.0}/src/fly_on_the_wall/providers/__init__.py +0 -0
  44. {fow_cli-0.1.0 → fow_cli-0.3.0}/src/fly_on_the_wall/reanalysis.py +0 -0
  45. {fow_cli-0.1.0 → fow_cli-0.3.0}/src/fly_on_the_wall/recording_quality.py +0 -0
  46. {fow_cli-0.1.0 → fow_cli-0.3.0}/src/fly_on_the_wall/rendering.py +0 -0
  47. {fow_cli-0.1.0 → fow_cli-0.3.0}/src/fly_on_the_wall/service_pricing.py +0 -0
  48. {fow_cli-0.1.0 → fow_cli-0.3.0}/src/fly_on_the_wall/speaker_identity.py +0 -0
  49. {fow_cli-0.1.0 → fow_cli-0.3.0}/src/fly_on_the_wall/speakers.py +0 -0
  50. {fow_cli-0.1.0 → fow_cli-0.3.0}/src/fly_on_the_wall/storage.py +0 -0
  51. {fow_cli-0.1.0 → fow_cli-0.3.0}/src/fly_on_the_wall/voice_samples.py +0 -0
@@ -0,0 +1,33 @@
1
+ # Changelog
2
+
3
+ All notable changes to Fly on the Wall are documented here.
4
+
5
+ ## [0.3.0] - 2026-06-13
6
+
7
+ ### Added
8
+
9
+ - Added glossary management with `fow glossary` commands.
10
+ - Added glossary and known-person hints for ElevenLabs transcription keyterms.
11
+ - Added glossary guidance to OpenAI cleanup, analysis, and title generation.
12
+ - Added Obsidian `participants` frontmatter links for known meeting speakers.
13
+
14
+ ## [0.2.0] - 2026-06-09
15
+
16
+ ### Added
17
+
18
+ - Added folder-level `--delete-originals-after-import` support for watched folders.
19
+ - Added `fow watch folders delete-originals-after-import` to toggle original cleanup for existing watch folders.
20
+ - Added a `py.typed` marker so editors and type checkers recognize the package as typed.
21
+ - Added pragmatic `basedpyright` type checking for source files and documented the code quality policy.
22
+
23
+ ### Fixed
24
+
25
+ - Avoided a tight retry loop when the watch backend fails, such as `Too many open files`.
26
+ - Resolved source-level `basedpyright` warnings.
27
+
28
+ ## [0.1.0] - 2026-06-09
29
+
30
+ ### Added
31
+
32
+ - Initial public release of the `fow` CLI as the `fow-cli` PyPI package.
33
+ - Published GitHub repository and release artifacts.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fow-cli
3
- Version: 0.1.0
3
+ Version: 0.3.0
4
4
  Summary: Personal CLI note-taker for turning meeting audio into cleaned meeting manuscripts.
5
5
  Project-URL: Repository, https://github.com/henriksvensson/fly-on-the-wall
6
6
  License-Expression: MIT
@@ -30,6 +30,10 @@ Description-Content-Type: text/markdown
30
30
 
31
31
  # Fly on the Wall
32
32
 
33
+ [![PyPI](https://img.shields.io/pypi/v/fow-cli.svg)](https://pypi.org/project/fow-cli/)
34
+ [![Python Versions](https://img.shields.io/pypi/pyversions/fow-cli.svg)](https://pypi.org/project/fow-cli/)
35
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
36
+
33
37
  Fly on the Wall is a personal CLI note-taker for meeting audio.
34
38
 
35
39
  It takes local audio recordings, transcribes them, identifies recurring speakers where possible, cleans the transcript, analyzes the meeting, exports durable Markdown artifacts, and can publish readable notes into an Obsidian vault.
@@ -46,6 +50,8 @@ Issues and suggestions are welcome via GitHub Issues, but the project is provide
46
50
 
47
51
  Audio is sent to configured transcription/AI providers during processing. Optional speaker identity embeddings run locally when installed with the `identity` extra. External providers may charge usage-based fees depending on your provider account, pricing plan, and processing volume.
48
52
 
53
+ Glossary/keyterm hints are sent to ElevenLabs when processing new recordings. ElevenLabs currently documents this as a billable add-on to speech-to-text usage.
54
+
49
55
  ## Development Transparency
50
56
 
51
57
  This project was developed as an agentic coding project using [OpenCode](https://opencode.ai/) with [OpenAI](https://openai.com/) GPT-5.5. Code quality checks were supported by CodeScene's [CodeHealth](https://codescene.com/product/code-health) analysis.
@@ -267,6 +273,32 @@ fow people embeddings status
267
273
  fow people embeddings backfill
268
274
  ```
269
275
 
276
+ ## Glossary
277
+
278
+ Use the glossary for names, company names, project names, product names, acronyms, and domain-specific phrases that transcription or cleanup models may spell incorrectly.
279
+
280
+ Add terms with optional context:
281
+
282
+ ```bash
283
+ fow glossary add "Hejare" --description "Company name"
284
+ fow glossary add "Datadrivna" --description "The phrase data driven in Swedish"
285
+ fow glossary add "Ants" --description "Company name"
286
+ fow glossary add "TT" --description "Company name, short for Theodora Tech"
287
+ ```
288
+
289
+ Manage terms:
290
+
291
+ ```bash
292
+ fow glossary list
293
+ fow glossary show "Hejare"
294
+ fow glossary update "TT" --description "Company name, short for Theodora Tech"
295
+ fow glossary disable "Ants"
296
+ fow glossary enable "Ants"
297
+ fow glossary remove "Ants"
298
+ ```
299
+
300
+ During processing, `fow` combines enabled glossary terms with known people names. The combined list is sent to ElevenLabs as transcription keyterms for new transcriptions, and to OpenAI cleanup, analysis, and title generation as spelling/context guidance. Corrections are model-mediated; `fow` does not do deterministic search-and-replace from the glossary.
301
+
270
302
  ## Watched Folders
271
303
 
272
304
  Fly on the Wall can watch local folders, mounted Dropbox/rclone folders, and removable recorder folders.
@@ -419,8 +451,11 @@ Run lint and formatting checks:
419
451
  ```bash
420
452
  uv run ruff check .
421
453
  uv run ruff format --check .
454
+ uv run basedpyright
422
455
  ```
423
456
 
457
+ `basedpyright` is configured as a pragmatic source-code guardrail. It checks explicit type claims in `src/` without requiring every dynamic SQLite, JSON, or third-party boundary to be fully typed.
458
+
424
459
  Build distribution artifacts:
425
460
 
426
461
  ```bash
@@ -430,7 +465,7 @@ uv build
430
465
  Test a built wheel locally:
431
466
 
432
467
  ```bash
433
- uv tool install dist/fly_on_the_wall-0.1.0-py3-none-any.whl
468
+ uv tool install dist/fow_cli-0.1.0-py3-none-any.whl
434
469
  fow setup
435
470
  ```
436
471
 
@@ -1,5 +1,9 @@
1
1
  # Fly on the Wall
2
2
 
3
+ [![PyPI](https://img.shields.io/pypi/v/fow-cli.svg)](https://pypi.org/project/fow-cli/)
4
+ [![Python Versions](https://img.shields.io/pypi/pyversions/fow-cli.svg)](https://pypi.org/project/fow-cli/)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
6
+
3
7
  Fly on the Wall is a personal CLI note-taker for meeting audio.
4
8
 
5
9
  It takes local audio recordings, transcribes them, identifies recurring speakers where possible, cleans the transcript, analyzes the meeting, exports durable Markdown artifacts, and can publish readable notes into an Obsidian vault.
@@ -16,6 +20,8 @@ Issues and suggestions are welcome via GitHub Issues, but the project is provide
16
20
 
17
21
  Audio is sent to configured transcription/AI providers during processing. Optional speaker identity embeddings run locally when installed with the `identity` extra. External providers may charge usage-based fees depending on your provider account, pricing plan, and processing volume.
18
22
 
23
+ Glossary/keyterm hints are sent to ElevenLabs when processing new recordings. ElevenLabs currently documents this as a billable add-on to speech-to-text usage.
24
+
19
25
  ## Development Transparency
20
26
 
21
27
  This project was developed as an agentic coding project using [OpenCode](https://opencode.ai/) with [OpenAI](https://openai.com/) GPT-5.5. Code quality checks were supported by CodeScene's [CodeHealth](https://codescene.com/product/code-health) analysis.
@@ -237,6 +243,32 @@ fow people embeddings status
237
243
  fow people embeddings backfill
238
244
  ```
239
245
 
246
+ ## Glossary
247
+
248
+ Use the glossary for names, company names, project names, product names, acronyms, and domain-specific phrases that transcription or cleanup models may spell incorrectly.
249
+
250
+ Add terms with optional context:
251
+
252
+ ```bash
253
+ fow glossary add "Hejare" --description "Company name"
254
+ fow glossary add "Datadrivna" --description "The phrase data driven in Swedish"
255
+ fow glossary add "Ants" --description "Company name"
256
+ fow glossary add "TT" --description "Company name, short for Theodora Tech"
257
+ ```
258
+
259
+ Manage terms:
260
+
261
+ ```bash
262
+ fow glossary list
263
+ fow glossary show "Hejare"
264
+ fow glossary update "TT" --description "Company name, short for Theodora Tech"
265
+ fow glossary disable "Ants"
266
+ fow glossary enable "Ants"
267
+ fow glossary remove "Ants"
268
+ ```
269
+
270
+ During processing, `fow` combines enabled glossary terms with known people names. The combined list is sent to ElevenLabs as transcription keyterms for new transcriptions, and to OpenAI cleanup, analysis, and title generation as spelling/context guidance. Corrections are model-mediated; `fow` does not do deterministic search-and-replace from the glossary.
271
+
240
272
  ## Watched Folders
241
273
 
242
274
  Fly on the Wall can watch local folders, mounted Dropbox/rclone folders, and removable recorder folders.
@@ -389,8 +421,11 @@ Run lint and formatting checks:
389
421
  ```bash
390
422
  uv run ruff check .
391
423
  uv run ruff format --check .
424
+ uv run basedpyright
392
425
  ```
393
426
 
427
+ `basedpyright` is configured as a pragmatic source-code guardrail. It checks explicit type claims in `src/` without requiring every dynamic SQLite, JSON, or third-party boundary to be fully typed.
428
+
394
429
  Build distribution artifacts:
395
430
 
396
431
  ```bash
@@ -400,7 +435,7 @@ uv build
400
435
  Test a built wheel locally:
401
436
 
402
437
  ```bash
403
- uv tool install dist/fly_on_the_wall-0.1.0-py3-none-any.whl
438
+ uv tool install dist/fow_cli-0.1.0-py3-none-any.whl
404
439
  fow setup
405
440
  ```
406
441
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "fow-cli"
3
- version = "0.1.0"
3
+ version = "0.3.0"
4
4
  description = "Personal CLI note-taker for turning meeting audio into cleaned meeting manuscripts."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -41,6 +41,7 @@ fow = "fly_on_the_wall.cli:app"
41
41
 
42
42
  [dependency-groups]
43
43
  dev = [
44
+ "basedpyright>=1.39.7",
44
45
  "pre-commit>=4.0.0",
45
46
  "pytest>=8.0.0",
46
47
  "ruff>=0.8.0",
@@ -58,6 +59,7 @@ ignore-vcs = true
58
59
  only-include = [
59
60
  "src/fly_on_the_wall",
60
61
  "README.md",
62
+ "CHANGELOG.md",
61
63
  "LICENSE",
62
64
  "pyproject.toml",
63
65
  ]
@@ -74,3 +76,25 @@ select = ["E", "F", "I", "UP", "B"]
74
76
  [tool.pytest.ini_options]
75
77
  testpaths = ["tests"]
76
78
  addopts = "-q"
79
+
80
+ [tool.basedpyright]
81
+ include = ["src"]
82
+ exclude = ["tests"]
83
+ reportAny = "none"
84
+ reportExplicitAny = "none"
85
+ reportUnknownVariableType = "none"
86
+ reportUnknownMemberType = "none"
87
+ reportUnknownArgumentType = "none"
88
+ reportUnknownParameterType = "none"
89
+ reportMissingParameterType = "none"
90
+ reportMissingTypeArgument = "none"
91
+ reportUnusedCallResult = "none"
92
+ reportUnannotatedClassAttribute = "none"
93
+ reportArgumentType = "error"
94
+ reportAssignmentType = "error"
95
+ reportReturnType = "error"
96
+ reportOperatorIssue = "error"
97
+ reportOptionalMemberAccess = "error"
98
+ reportAttributeAccessIssue = "error"
99
+ reportCallIssue = "error"
100
+ reportImportCycles = "error"
@@ -1,3 +1,3 @@
1
1
  """Fly on the Wall CLI application."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.3.0"
@@ -0,0 +1,6 @@
1
+ from __future__ import annotations
2
+
3
+ API_KEY_ENV_VARS: dict[str, str] = {
4
+ "elevenlabs": "ELEVENLABS_API_KEY",
5
+ "openai": "OPENAI_API_KEY",
6
+ }
@@ -6,6 +6,7 @@ from dataclasses import dataclass
6
6
  from datetime import datetime
7
7
  from pathlib import Path
8
8
  from sqlite3 import Connection
9
+ from typing import Any, cast
9
10
 
10
11
  from fly_on_the_wall.audio import AudioError, probe_metadata
11
12
  from fly_on_the_wall.storage import StoragePaths
@@ -103,9 +104,13 @@ def extract_and_store_audio_metadata(
103
104
  )
104
105
 
105
106
 
106
- def normalize_audio_metadata(raw_metadata: dict, audio_path: Path) -> NormalizedAudioMetadata:
107
+ JsonObject = dict[str, Any]
108
+
109
+
110
+ def normalize_audio_metadata(raw_metadata: JsonObject, audio_path: Path) -> NormalizedAudioMetadata:
107
111
  audio_stream = _first_audio_stream(raw_metadata)
108
- format_data = raw_metadata.get("format") if isinstance(raw_metadata.get("format"), dict) else {}
112
+ raw_format = raw_metadata.get("format")
113
+ format_data: JsonObject = cast(JsonObject, raw_format) if isinstance(raw_format, dict) else {}
109
114
  format_tags = _normalized_tags(format_data.get("tags"))
110
115
  stream_tags = _normalized_tags(audio_stream.get("tags"))
111
116
  tags = {**stream_tags, **format_tags}
@@ -133,13 +138,13 @@ def normalize_audio_metadata(raw_metadata: dict, audio_path: Path) -> Normalized
133
138
  )
134
139
 
135
140
 
136
- def _first_audio_stream(raw_metadata: dict) -> dict:
141
+ def _first_audio_stream(raw_metadata: JsonObject) -> JsonObject:
137
142
  streams = raw_metadata.get("streams")
138
143
  if not isinstance(streams, list):
139
144
  return {}
140
145
  for stream in streams:
141
146
  if isinstance(stream, dict) and stream.get("codec_type") == "audio":
142
- return stream
147
+ return cast(JsonObject, stream)
143
148
  return {}
144
149
 
145
150
 
@@ -228,14 +233,22 @@ def _optional_str(value: object) -> str | None:
228
233
 
229
234
 
230
235
  def _optional_int(value: object) -> int | None:
236
+ if value is None:
237
+ return None
231
238
  try:
232
- return int(value) if value is not None else None
239
+ if isinstance(value, int | float | str | bytes | bytearray):
240
+ return int(value)
241
+ return int(str(value))
233
242
  except (TypeError, ValueError):
234
243
  return None
235
244
 
236
245
 
237
246
  def _optional_float(value: object) -> float | None:
247
+ if value is None:
248
+ return None
238
249
  try:
239
- return float(value) if value is not None else None
250
+ if isinstance(value, int | float | str | bytes | bytearray):
251
+ return float(value)
252
+ return float(str(value))
240
253
  except (TypeError, ValueError):
241
254
  return None
@@ -9,6 +9,7 @@ from rich.table import Table
9
9
 
10
10
  from fly_on_the_wall import __version__
11
11
  from fly_on_the_wall.cli_costs import costs_app
12
+ from fly_on_the_wall.cli_glossary import glossary_app
12
13
  from fly_on_the_wall.cli_publish import publish_app
13
14
  from fly_on_the_wall.cli_speaker_review import speakers_review
14
15
  from fly_on_the_wall.cli_watch import watch_app
@@ -78,6 +79,7 @@ app.add_typer(meetings_app, name="meetings")
78
79
  meetings_app.add_typer(meeting_speakers_app, name="speakers")
79
80
  app.add_typer(refresh_app, name="refresh")
80
81
  app.add_typer(secrets_app, name="secrets")
82
+ app.add_typer(glossary_app, name="glossary")
81
83
  app.add_typer(watch_app, name="watch")
82
84
  app.add_typer(publish_app, name="publish")
83
85
  app.add_typer(costs_app, name="costs")
@@ -93,13 +95,15 @@ def _version_callback(show_version: bool) -> None:
93
95
 
94
96
  @app.callback()
95
97
  def main(
96
- version: bool = typer.Option(
97
- False,
98
- "--version",
99
- callback=_version_callback,
100
- is_eager=True,
101
- help="Show the application version.",
102
- ),
98
+ _version: Annotated[
99
+ bool,
100
+ typer.Option(
101
+ "--version",
102
+ callback=_version_callback,
103
+ is_eager=True,
104
+ help="Show the application version.",
105
+ ),
106
+ ] = False,
103
107
  ) -> None:
104
108
  """Run Fly on the Wall commands."""
105
109
 
@@ -0,0 +1,124 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Annotated
4
+
5
+ import typer
6
+ from rich.console import Console
7
+ from rich.table import Table
8
+
9
+ from fly_on_the_wall.db import database
10
+ from fly_on_the_wall.glossary import (
11
+ create_glossary_term,
12
+ get_glossary_term,
13
+ list_glossary_terms,
14
+ remove_glossary_term,
15
+ update_glossary_term,
16
+ )
17
+
18
+ glossary_app = typer.Typer(help="Manage transcription and cleanup glossary terms.", no_args_is_help=True)
19
+ console = Console()
20
+
21
+
22
+ @glossary_app.command("add")
23
+ def glossary_add(
24
+ term: str,
25
+ description: Annotated[str | None, typer.Option("--description", "-d", help="Optional context.")] = None,
26
+ ) -> None:
27
+ """Add a word or phrase to the glossary."""
28
+ with database() as connection:
29
+ try:
30
+ created = create_glossary_term(connection, term, description)
31
+ except ValueError as exc:
32
+ console.print(str(exc))
33
+ raise typer.Exit(code=1) from exc
34
+ console.print(f"Added glossary term: {created.term}")
35
+
36
+
37
+ @glossary_app.command("list")
38
+ def glossary_list(
39
+ all_terms: Annotated[bool, typer.Option("--all", help="Include disabled terms.")] = False,
40
+ ) -> None:
41
+ """List glossary terms."""
42
+ with database() as connection:
43
+ terms = list_glossary_terms(connection, include_disabled=all_terms)
44
+ if not terms:
45
+ console.print("No glossary terms found.")
46
+ return
47
+
48
+ table = Table(title="Glossary")
49
+ table.add_column("Term")
50
+ table.add_column("Description")
51
+ table.add_column("Enabled")
52
+ for term in terms:
53
+ table.add_row(term.term, term.description or "", "yes" if term.enabled else "no")
54
+ console.print(table)
55
+
56
+
57
+ @glossary_app.command("show")
58
+ def glossary_show(term: str) -> None:
59
+ """Show one glossary term."""
60
+ with database() as connection:
61
+ found = get_glossary_term(connection, term)
62
+ if found is None:
63
+ console.print(f"Glossary term not found: {term}")
64
+ raise typer.Exit(code=1)
65
+ console.print(f"Term: {found.term}")
66
+ console.print(f"Description: {found.description or ''}")
67
+ console.print(f"Enabled: {'yes' if found.enabled else 'no'}")
68
+ console.print(f"ID: {found.id}")
69
+
70
+
71
+ @glossary_app.command("update")
72
+ def glossary_update(
73
+ term: str,
74
+ new_term: Annotated[str | None, typer.Option("--term", help="Replace the glossary term text.")] = None,
75
+ description: Annotated[str | None, typer.Option("--description", "-d", help="Replace the description.")] = None,
76
+ ) -> None:
77
+ """Update a glossary term or description."""
78
+ with database() as connection:
79
+ try:
80
+ updated = update_glossary_term(connection, term, term=new_term, description=description)
81
+ except ValueError as exc:
82
+ console.print(str(exc))
83
+ raise typer.Exit(code=1) from exc
84
+ console.print(f"Updated glossary term: {updated.term}")
85
+
86
+
87
+ @glossary_app.command("enable")
88
+ def glossary_enable(term: str) -> None:
89
+ """Enable a glossary term."""
90
+ _set_enabled(term, True)
91
+
92
+
93
+ @glossary_app.command("disable")
94
+ def glossary_disable(term: str) -> None:
95
+ """Disable a glossary term without deleting it."""
96
+ _set_enabled(term, False)
97
+
98
+
99
+ @glossary_app.command("remove")
100
+ def glossary_remove(
101
+ term: str,
102
+ yes: Annotated[bool, typer.Option("--yes", "-y", help="Remove without confirmation.")] = False,
103
+ ) -> None:
104
+ """Remove a glossary term."""
105
+ if not yes and not typer.confirm(f"Remove glossary term '{term}'?", default=False):
106
+ console.print("Cancelled.")
107
+ return
108
+ with database() as connection:
109
+ removed = remove_glossary_term(connection, term)
110
+ if not removed:
111
+ console.print(f"Glossary term not found: {term}")
112
+ raise typer.Exit(code=1)
113
+ console.print(f"Removed glossary term: {term}")
114
+
115
+
116
+ def _set_enabled(term: str, enabled: bool) -> None:
117
+ with database() as connection:
118
+ try:
119
+ updated = update_glossary_term(connection, term, enabled=enabled)
120
+ except ValueError as exc:
121
+ console.print(str(exc))
122
+ raise typer.Exit(code=1) from exc
123
+ state = "Enabled" if enabled else "Disabled"
124
+ console.print(f"{state} glossary term: {updated.term}")
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import subprocess
4
4
  import threading
5
+ from collections.abc import Callable
5
6
  from dataclasses import dataclass
6
7
  from pathlib import Path
7
8
 
@@ -55,25 +56,11 @@ class InteractiveMenu:
55
56
  )
56
57
 
57
58
  def _bind_navigation_keys(self) -> None:
58
- @self.key_bindings.add("up")
59
- def _up(_event) -> None:
60
- self._move(-1)
61
-
62
- @self.key_bindings.add("down")
63
- def _down(_event) -> None:
64
- self._move(1)
65
-
66
- @self.key_bindings.add("enter")
67
- def _enter(_event) -> None:
68
- if self._playback_is_running():
69
- self._stop_playback()
70
- return
71
- self._finish(self.choices[self.selected_index])
72
-
73
- @self.key_bindings.add("escape")
74
- @self.key_bindings.add("c-c")
75
- def _cancel(_event) -> None:
76
- self._cancel()
59
+ self.key_bindings.add("up")(self._handle_up)
60
+ self.key_bindings.add("down")(self._handle_down)
61
+ self.key_bindings.add("enter")(self._handle_enter)
62
+ self.key_bindings.add("escape")(self._handle_cancel)
63
+ self.key_bindings.add("c-c")(self._handle_cancel)
77
64
 
78
65
  def _bind_shortcut_keys(self) -> None:
79
66
  bound_shortcuts: set[str] = set()
@@ -81,7 +68,28 @@ class InteractiveMenu:
81
68
  if choice.shortcut is None or choice.shortcut in bound_shortcuts:
82
69
  continue
83
70
  bound_shortcuts.add(choice.shortcut)
84
- self.key_bindings.add(choice.shortcut)(lambda _event, selected=choice: self._finish(selected))
71
+ self.key_bindings.add(choice.shortcut)(self._shortcut_handler(choice))
72
+
73
+ def _handle_up(self, _event: object) -> None:
74
+ self._move(-1)
75
+
76
+ def _handle_down(self, _event: object) -> None:
77
+ self._move(1)
78
+
79
+ def _handle_enter(self, _event: object) -> None:
80
+ if self._playback_is_running():
81
+ self._stop_playback()
82
+ return
83
+ self._finish(self.choices[self.selected_index])
84
+
85
+ def _handle_cancel(self, _event: object) -> None:
86
+ self._cancel()
87
+
88
+ def _shortcut_handler(self, choice: MenuChoice) -> Callable[[object], None]:
89
+ def handle_shortcut(_event: object) -> None:
90
+ self._finish(choice)
91
+
92
+ return handle_shortcut
85
93
 
86
94
  def _finish(self, choice: MenuChoice) -> None:
87
95
  if choice.playback_path is not None:
@@ -17,6 +17,7 @@ from fly_on_the_wall.watch import (
17
17
  list_watch_folders,
18
18
  remove_watch_folder,
19
19
  scan_watch_folders,
20
+ set_watch_folder_delete_originals_after_import,
20
21
  set_watch_folder_enabled,
21
22
  )
22
23
 
@@ -77,17 +78,26 @@ def watch_run(
77
78
  def watch_folders_add(
78
79
  path: Annotated[Path, typer.Argument(file_okay=False, dir_okay=True)],
79
80
  name: Annotated[str | None, typer.Option("--name", "-n", help="Optional folder name.")] = None,
81
+ delete_originals_after_import: Annotated[
82
+ bool,
83
+ typer.Option(
84
+ "--delete-originals-after-import",
85
+ help="Delete source audio files after this watch folder imports them successfully.",
86
+ ),
87
+ ] = False,
80
88
  ) -> None:
81
89
  """Add a folder to scan for audio files."""
82
90
  with database() as connection:
83
91
  try:
84
- folder = add_watch_folder(connection, path, name)
92
+ folder = add_watch_folder(connection, path, name, delete_originals_after_import)
85
93
  except Exception as exc:
86
94
  console.print(str(exc))
87
95
  raise typer.Exit(code=1) from exc
88
96
  console.print(f"Added watch folder {folder.path}")
89
97
  if folder.name:
90
98
  console.print(f"Name: {folder.name}")
99
+ if folder.delete_originals_after_import:
100
+ console.print("Original audio files will be deleted after successful import.")
91
101
 
92
102
 
93
103
  @watch_folders_app.command("list")
@@ -102,12 +112,14 @@ def watch_folders_list() -> None:
102
112
  table.add_column("ID")
103
113
  table.add_column("Name")
104
114
  table.add_column("Enabled")
115
+ table.add_column("Delete Originals")
105
116
  table.add_column("Path")
106
117
  for folder in folders:
107
118
  table.add_row(
108
119
  folder.id,
109
120
  folder.name or "",
110
121
  "yes" if folder.enabled else "no",
122
+ "yes" if folder.delete_originals_after_import else "no",
111
123
  str(folder.path),
112
124
  )
113
125
  console.print(table)
@@ -136,6 +148,27 @@ def watch_folders_disable(identifier: str) -> None:
136
148
  _set_watch_folder_enabled_command(identifier, False)
137
149
 
138
150
 
151
+ @watch_folders_app.command("delete-originals-after-import")
152
+ def watch_folders_delete_originals_after_import(
153
+ identifier: str,
154
+ enabled: Annotated[
155
+ bool,
156
+ typer.Option(
157
+ "--enabled/--disabled",
158
+ help="Whether this folder deletes source audio files after successful import.",
159
+ ),
160
+ ],
161
+ ) -> None:
162
+ """Configure original audio deletion after import for a watched folder."""
163
+ with database() as connection:
164
+ folder = set_watch_folder_delete_originals_after_import(connection, identifier, enabled)
165
+ if folder is None:
166
+ console.print(f"Watch folder not found: {identifier}")
167
+ raise typer.Exit(code=1)
168
+ state = "enabled" if enabled else "disabled"
169
+ console.print(f"Delete originals after import {state} for {folder.path}")
170
+
171
+
139
172
  def _watch_run_once(config, stable_age_seconds: int, interval_seconds: int) -> None:
140
173
  existing_paths = _existing_watch_paths()
141
174
  if not existing_paths:
@@ -146,7 +179,9 @@ def _watch_run_once(config, stable_age_seconds: int, interval_seconds: int) -> N
146
179
 
147
180
  changes = _watch_for_changes(existing_paths, interval_seconds)
148
181
  if changes is None:
182
+ console.print("Watch backend unavailable. Running safety scan before retry delay.")
149
183
  _scan_watch_once(config, stable_age_seconds)
184
+ sleep(interval_seconds)
150
185
  return
151
186
 
152
187
  _print_watch_changes(changes)
@@ -192,11 +227,12 @@ def _scan_watch_once(config, stable_age_seconds: int) -> None:
192
227
  stable_age_seconds=stable_age_seconds,
193
228
  progress=lambda message: console.print(f"-> {message}"),
194
229
  )
195
- console.print(
230
+ message = (
196
231
  f"Watch scan complete: {result.processed} processed, "
197
232
  f"{result.ignored} ignored, {result.skipped} skipped, "
198
233
  f"{result.failed} failed, {result.seen} seen."
199
234
  )
235
+ console.print(message)
200
236
 
201
237
 
202
238
  def _set_watch_folder_enabled_command(identifier: str, enabled: bool) -> None:
@@ -14,11 +14,6 @@ GLOSSARY_FILE_NAME = "glossary.yaml"
14
14
  ProviderName = Literal["elevenlabs", "openai"]
15
15
  CleanupMode = Literal["off", "deterministic", "light"]
16
16
 
17
- API_KEY_ENV_VARS: dict[str, str] = {
18
- "elevenlabs": "ELEVENLABS_API_KEY",
19
- "openai": "OPENAI_API_KEY",
20
- }
21
-
22
17
 
23
18
  class ConfigError(RuntimeError):
24
19
  """Raised when the application config cannot be loaded."""