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.
- fow_cli-0.3.0/CHANGELOG.md +33 -0
- {fow_cli-0.1.0 → fow_cli-0.3.0}/PKG-INFO +37 -2
- {fow_cli-0.1.0 → fow_cli-0.3.0}/README.md +36 -1
- {fow_cli-0.1.0 → fow_cli-0.3.0}/pyproject.toml +25 -1
- {fow_cli-0.1.0 → fow_cli-0.3.0}/src/fly_on_the_wall/__init__.py +1 -1
- fow_cli-0.3.0/src/fly_on_the_wall/api_keys.py +6 -0
- {fow_cli-0.1.0 → fow_cli-0.3.0}/src/fly_on_the_wall/audio_metadata.py +19 -6
- {fow_cli-0.1.0 → fow_cli-0.3.0}/src/fly_on_the_wall/cli.py +11 -7
- fow_cli-0.3.0/src/fly_on_the_wall/cli_glossary.py +124 -0
- {fow_cli-0.1.0 → fow_cli-0.3.0}/src/fly_on_the_wall/cli_menu.py +28 -20
- {fow_cli-0.1.0 → fow_cli-0.3.0}/src/fly_on_the_wall/cli_watch.py +38 -2
- {fow_cli-0.1.0 → fow_cli-0.3.0}/src/fly_on_the_wall/config.py +0 -5
- {fow_cli-0.1.0 → fow_cli-0.3.0}/src/fly_on_the_wall/db.py +20 -3
- {fow_cli-0.1.0 → fow_cli-0.3.0}/src/fly_on_the_wall/embeddings.py +26 -5
- fow_cli-0.3.0/src/fly_on_the_wall/glossary.py +207 -0
- {fow_cli-0.1.0 → fow_cli-0.3.0}/src/fly_on_the_wall/processing.py +46 -26
- {fow_cli-0.1.0 → fow_cli-0.3.0}/src/fly_on_the_wall/providers/elevenlabs.py +19 -4
- {fow_cli-0.1.0 → fow_cli-0.3.0}/src/fly_on_the_wall/providers/openai_analysis.py +27 -8
- {fow_cli-0.1.0 → fow_cli-0.3.0}/src/fly_on_the_wall/providers/openai_cleanup.py +15 -5
- {fow_cli-0.1.0 → fow_cli-0.3.0}/src/fly_on_the_wall/publishing.py +32 -3
- fow_cli-0.3.0/src/fly_on_the_wall/py.typed +0 -0
- {fow_cli-0.1.0 → fow_cli-0.3.0}/src/fly_on_the_wall/secrets.py +3 -3
- {fow_cli-0.1.0 → fow_cli-0.3.0}/src/fly_on_the_wall/setup.py +4 -2
- {fow_cli-0.1.0 → fow_cli-0.3.0}/src/fly_on_the_wall/speaker_matching.py +9 -2
- {fow_cli-0.1.0 → fow_cli-0.3.0}/src/fly_on_the_wall/watch.py +57 -11
- fow_cli-0.1.0/src/fly_on_the_wall/glossary.py +0 -31
- {fow_cli-0.1.0 → fow_cli-0.3.0}/.gitignore +0 -0
- {fow_cli-0.1.0 → fow_cli-0.3.0}/LICENSE +0 -0
- {fow_cli-0.1.0 → fow_cli-0.3.0}/src/fly_on_the_wall/audio.py +0 -0
- {fow_cli-0.1.0 → fow_cli-0.3.0}/src/fly_on_the_wall/cache.py +0 -0
- {fow_cli-0.1.0 → fow_cli-0.3.0}/src/fly_on_the_wall/cleanup.py +0 -0
- {fow_cli-0.1.0 → fow_cli-0.3.0}/src/fly_on_the_wall/cli_costs.py +0 -0
- {fow_cli-0.1.0 → fow_cli-0.3.0}/src/fly_on_the_wall/cli_publish.py +0 -0
- {fow_cli-0.1.0 → fow_cli-0.3.0}/src/fly_on_the_wall/cli_speaker_review.py +0 -0
- {fow_cli-0.1.0 → fow_cli-0.3.0}/src/fly_on_the_wall/costs.py +0 -0
- {fow_cli-0.1.0 → fow_cli-0.3.0}/src/fly_on_the_wall/doctor.py +0 -0
- {fow_cli-0.1.0 → fow_cli-0.3.0}/src/fly_on_the_wall/exporting.py +0 -0
- {fow_cli-0.1.0 → fow_cli-0.3.0}/src/fly_on_the_wall/meetings.py +0 -0
- {fow_cli-0.1.0 → fow_cli-0.3.0}/src/fly_on_the_wall/normalization.py +0 -0
- {fow_cli-0.1.0 → fow_cli-0.3.0}/src/fly_on_the_wall/people.py +0 -0
- {fow_cli-0.1.0 → fow_cli-0.3.0}/src/fly_on_the_wall/people_embeddings.py +0 -0
- {fow_cli-0.1.0 → fow_cli-0.3.0}/src/fly_on_the_wall/pipeline.py +0 -0
- {fow_cli-0.1.0 → fow_cli-0.3.0}/src/fly_on_the_wall/providers/__init__.py +0 -0
- {fow_cli-0.1.0 → fow_cli-0.3.0}/src/fly_on_the_wall/reanalysis.py +0 -0
- {fow_cli-0.1.0 → fow_cli-0.3.0}/src/fly_on_the_wall/recording_quality.py +0 -0
- {fow_cli-0.1.0 → fow_cli-0.3.0}/src/fly_on_the_wall/rendering.py +0 -0
- {fow_cli-0.1.0 → fow_cli-0.3.0}/src/fly_on_the_wall/service_pricing.py +0 -0
- {fow_cli-0.1.0 → fow_cli-0.3.0}/src/fly_on_the_wall/speaker_identity.py +0 -0
- {fow_cli-0.1.0 → fow_cli-0.3.0}/src/fly_on_the_wall/speakers.py +0 -0
- {fow_cli-0.1.0 → fow_cli-0.3.0}/src/fly_on_the_wall/storage.py +0 -0
- {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.
|
|
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
|
+
[](https://pypi.org/project/fow-cli/)
|
|
34
|
+
[](https://pypi.org/project/fow-cli/)
|
|
35
|
+
[](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/
|
|
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
|
+
[](https://pypi.org/project/fow-cli/)
|
|
4
|
+
[](https://pypi.org/project/fow-cli/)
|
|
5
|
+
[](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/
|
|
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.
|
|
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"
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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)(
|
|
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
|
-
|
|
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."""
|