fow-cli 0.1.0__py3-none-any.whl
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.
- fly_on_the_wall/__init__.py +3 -0
- fly_on_the_wall/audio.py +164 -0
- fly_on_the_wall/audio_metadata.py +241 -0
- fly_on_the_wall/cache.py +26 -0
- fly_on_the_wall/cleanup.py +29 -0
- fly_on_the_wall/cli.py +641 -0
- fly_on_the_wall/cli_costs.py +81 -0
- fly_on_the_wall/cli_menu.py +163 -0
- fly_on_the_wall/cli_publish.py +141 -0
- fly_on_the_wall/cli_speaker_review.py +315 -0
- fly_on_the_wall/cli_watch.py +209 -0
- fly_on_the_wall/config.py +92 -0
- fly_on_the_wall/costs.py +169 -0
- fly_on_the_wall/db.py +508 -0
- fly_on_the_wall/doctor.py +142 -0
- fly_on_the_wall/embeddings.py +142 -0
- fly_on_the_wall/exporting.py +155 -0
- fly_on_the_wall/glossary.py +31 -0
- fly_on_the_wall/meetings.py +382 -0
- fly_on_the_wall/normalization.py +166 -0
- fly_on_the_wall/people.py +82 -0
- fly_on_the_wall/people_embeddings.py +68 -0
- fly_on_the_wall/pipeline.py +120 -0
- fly_on_the_wall/processing.py +427 -0
- fly_on_the_wall/providers/__init__.py +1 -0
- fly_on_the_wall/providers/elevenlabs.py +145 -0
- fly_on_the_wall/providers/openai_analysis.py +195 -0
- fly_on_the_wall/providers/openai_cleanup.py +91 -0
- fly_on_the_wall/publishing.py +410 -0
- fly_on_the_wall/reanalysis.py +172 -0
- fly_on_the_wall/recording_quality.py +141 -0
- fly_on_the_wall/rendering.py +115 -0
- fly_on_the_wall/secrets.py +93 -0
- fly_on_the_wall/service_pricing.py +75 -0
- fly_on_the_wall/setup.py +221 -0
- fly_on_the_wall/speaker_identity.py +173 -0
- fly_on_the_wall/speaker_matching.py +134 -0
- fly_on_the_wall/speakers.py +221 -0
- fly_on_the_wall/storage.py +53 -0
- fly_on_the_wall/voice_samples.py +125 -0
- fly_on_the_wall/watch.py +347 -0
- fow_cli-0.1.0.dist-info/METADATA +447 -0
- fow_cli-0.1.0.dist-info/RECORD +46 -0
- fow_cli-0.1.0.dist-info/WHEEL +4 -0
- fow_cli-0.1.0.dist-info/entry_points.txt +2 -0
- fow_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
|
|
10
|
+
from fly_on_the_wall.audio import AudioError
|
|
11
|
+
from fly_on_the_wall.cli_menu import MenuChoice, select_menu
|
|
12
|
+
from fly_on_the_wall.config import load_config
|
|
13
|
+
from fly_on_the_wall.db import database
|
|
14
|
+
from fly_on_the_wall.embeddings import EmbeddingBackend, PyannoteEmbeddingBackend
|
|
15
|
+
from fly_on_the_wall.people import Person, list_people
|
|
16
|
+
from fly_on_the_wall.processing import refresh_meeting
|
|
17
|
+
from fly_on_the_wall.reanalysis import rerun_speaker_matching_for_meetings
|
|
18
|
+
from fly_on_the_wall.speaker_identity import (
|
|
19
|
+
create_voice_identity_from_speaker,
|
|
20
|
+
prepare_speaker_review_clip,
|
|
21
|
+
)
|
|
22
|
+
from fly_on_the_wall.speakers import (
|
|
23
|
+
assign_speaker_to_person,
|
|
24
|
+
confirm_speaker_assignment,
|
|
25
|
+
list_review_speakers,
|
|
26
|
+
mark_speaker_ignored,
|
|
27
|
+
speaker_examples,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
console = Console()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class SpeakerReviewResult:
|
|
35
|
+
backend: EmbeddingBackend | None
|
|
36
|
+
changed_meeting: str | None = None
|
|
37
|
+
quit_review: bool = False
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def speakers_review(
|
|
41
|
+
meeting: Annotated[str | None, typer.Option("--meeting", "-m", help="Meeting ID or slug.")] = None,
|
|
42
|
+
include_uncertain: Annotated[
|
|
43
|
+
bool,
|
|
44
|
+
typer.Option("--include-uncertain", help="Also review uncertain speaker matches."),
|
|
45
|
+
] = False,
|
|
46
|
+
only_uncertain: Annotated[
|
|
47
|
+
bool,
|
|
48
|
+
typer.Option("--only-uncertain", help="Review only uncertain speaker matches."),
|
|
49
|
+
] = False,
|
|
50
|
+
) -> None:
|
|
51
|
+
"""Interactively review and assign unknown or uncertain meeting speakers."""
|
|
52
|
+
backend: EmbeddingBackend | None = None
|
|
53
|
+
changed_meetings: set[str] = set()
|
|
54
|
+
with database() as connection:
|
|
55
|
+
speakers = list_review_speakers(connection, meeting, include_uncertain, only_uncertain)
|
|
56
|
+
if not speakers:
|
|
57
|
+
console.print("No speakers found for review.")
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
for speaker in speakers:
|
|
61
|
+
review = _review_one_speaker(connection, speaker, backend)
|
|
62
|
+
backend = review.backend
|
|
63
|
+
if review.changed_meeting is not None:
|
|
64
|
+
changed_meetings.add(review.changed_meeting)
|
|
65
|
+
if review.quit_review:
|
|
66
|
+
break
|
|
67
|
+
|
|
68
|
+
_refresh_reviewed_meetings(connection, changed_meetings, backend)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _review_one_speaker(connection, speaker, backend: EmbeddingBackend | None) -> SpeakerReviewResult:
|
|
72
|
+
_print_speaker_review_prompt(connection, speaker)
|
|
73
|
+
clip_path = _speaker_review_clip(connection, speaker["id"])
|
|
74
|
+
while True:
|
|
75
|
+
action = _select_speaker_review_action(clip_path, speaker.get("review_kind") == "uncertain")
|
|
76
|
+
result = _apply_speaker_review_action(connection, speaker, action, backend)
|
|
77
|
+
backend = result.backend
|
|
78
|
+
if _speaker_review_action_finished(action, result):
|
|
79
|
+
return result
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _speaker_review_action_finished(action: str, result: SpeakerReviewResult) -> bool:
|
|
83
|
+
return result.changed_meeting is not None or result.quit_review or action == "s"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _print_speaker_review_prompt(connection, speaker) -> None:
|
|
87
|
+
console.print(f"Meeting speaker: {speaker['id']}")
|
|
88
|
+
console.print(f"Meeting: {speaker['meeting_slug']}")
|
|
89
|
+
console.print(f"Provider label: {speaker['label']}")
|
|
90
|
+
if speaker.get("review_kind") == "uncertain":
|
|
91
|
+
console.print(f"Suggested person: {speaker['suggested_person_name']}")
|
|
92
|
+
if speaker.get("confidence") is not None:
|
|
93
|
+
console.print(f"Confidence: {speaker['confidence']:.3f}")
|
|
94
|
+
if speaker.get("margin") is not None:
|
|
95
|
+
console.print(f"Margin: {speaker['margin']:.3f}")
|
|
96
|
+
examples = speaker_examples(connection, speaker["id"], limit=1)
|
|
97
|
+
if examples:
|
|
98
|
+
console.print(f"Example: {examples[0]['text']}")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _speaker_review_clip(connection, speaker_id: str) -> Path | None:
|
|
102
|
+
try:
|
|
103
|
+
clip_path = prepare_speaker_review_clip(connection, speaker_id)
|
|
104
|
+
except AudioError as exc:
|
|
105
|
+
console.print(f"Could not extract review clip: {exc}")
|
|
106
|
+
return None
|
|
107
|
+
if clip_path is not None:
|
|
108
|
+
console.print(f"Clip: {clip_path}")
|
|
109
|
+
return clip_path
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _apply_speaker_review_action(
|
|
113
|
+
connection, speaker, action: str, backend: EmbeddingBackend | None
|
|
114
|
+
) -> SpeakerReviewResult:
|
|
115
|
+
if action == "a":
|
|
116
|
+
return _assign_with_voice_sample(connection, speaker, backend)
|
|
117
|
+
if action == "n":
|
|
118
|
+
return _assign_without_voice_sample(connection, speaker, backend)
|
|
119
|
+
if action == "c":
|
|
120
|
+
return _create_person_with_voice_sample(connection, speaker, backend)
|
|
121
|
+
if action == "o":
|
|
122
|
+
return _create_person_without_voice_sample(connection, speaker, backend)
|
|
123
|
+
if action == "v":
|
|
124
|
+
return _confirm_suggested_assignment(connection, speaker, backend)
|
|
125
|
+
return _apply_non_assignment_action(connection, speaker, action, backend)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _apply_non_assignment_action(
|
|
129
|
+
connection, speaker, action: str, backend: EmbeddingBackend | None
|
|
130
|
+
) -> SpeakerReviewResult:
|
|
131
|
+
if action == "i":
|
|
132
|
+
mark_speaker_ignored(connection, speaker["id"])
|
|
133
|
+
console.print("Ignored meeting speaker; it will not appear in future reviews.")
|
|
134
|
+
return SpeakerReviewResult(backend, speaker["meeting_slug"])
|
|
135
|
+
if action == "s":
|
|
136
|
+
console.print("Skipped.")
|
|
137
|
+
return SpeakerReviewResult(backend)
|
|
138
|
+
if action == "q":
|
|
139
|
+
console.print("Review cancelled.")
|
|
140
|
+
return SpeakerReviewResult(backend, quit_review=True)
|
|
141
|
+
console.print("Choose an available action.")
|
|
142
|
+
return SpeakerReviewResult(backend)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _assign_with_voice_sample(connection, speaker, backend: EmbeddingBackend | None) -> SpeakerReviewResult:
|
|
146
|
+
person = _select_person(connection)
|
|
147
|
+
if person is None:
|
|
148
|
+
console.print("Assignment cancelled.")
|
|
149
|
+
return SpeakerReviewResult(backend)
|
|
150
|
+
backend = backend or _try_embedding_backend()
|
|
151
|
+
try:
|
|
152
|
+
result = create_voice_identity_from_speaker(connection, speaker["id"], person.id, storage=None, backend=backend)
|
|
153
|
+
except ValueError as exc:
|
|
154
|
+
console.print(str(exc))
|
|
155
|
+
return SpeakerReviewResult(backend)
|
|
156
|
+
console.print(f"Assigned meeting speaker to {result.person_name}")
|
|
157
|
+
console.print(f"Voice sample: {result.voice_sample.audio_path}")
|
|
158
|
+
return SpeakerReviewResult(backend, speaker["meeting_slug"])
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _assign_without_voice_sample(connection, speaker, backend: EmbeddingBackend | None) -> SpeakerReviewResult:
|
|
162
|
+
person = _select_person(connection)
|
|
163
|
+
if person is None:
|
|
164
|
+
console.print("Assignment cancelled.")
|
|
165
|
+
return SpeakerReviewResult(backend)
|
|
166
|
+
assignment = assign_speaker_to_person(connection, speaker["id"], person.id)
|
|
167
|
+
console.print(f"Assigned meeting speaker to {assignment['name']} without voice sample.")
|
|
168
|
+
return SpeakerReviewResult(backend, speaker["meeting_slug"])
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _create_person_with_voice_sample(connection, speaker, backend: EmbeddingBackend | None) -> SpeakerReviewResult:
|
|
172
|
+
name = typer.prompt("New known person name")
|
|
173
|
+
backend = backend or _try_embedding_backend()
|
|
174
|
+
try:
|
|
175
|
+
result = create_voice_identity_from_speaker(
|
|
176
|
+
connection,
|
|
177
|
+
speaker["id"],
|
|
178
|
+
name,
|
|
179
|
+
create_missing_person=True,
|
|
180
|
+
storage=None,
|
|
181
|
+
backend=backend,
|
|
182
|
+
)
|
|
183
|
+
except ValueError as exc:
|
|
184
|
+
console.print(str(exc))
|
|
185
|
+
return SpeakerReviewResult(backend)
|
|
186
|
+
console.print(f"Created known person {result.person_name}")
|
|
187
|
+
console.print(f"Voice sample: {result.voice_sample.audio_path}")
|
|
188
|
+
return SpeakerReviewResult(backend, speaker["meeting_slug"])
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _create_person_without_voice_sample(connection, speaker, backend: EmbeddingBackend | None) -> SpeakerReviewResult:
|
|
192
|
+
name = typer.prompt("New known person name")
|
|
193
|
+
assignment = assign_speaker_to_person(connection, speaker["id"], name)
|
|
194
|
+
console.print(f"Created known person {assignment['name']}")
|
|
195
|
+
console.print(f"Assigned meeting speaker to {assignment['name']} without voice sample.")
|
|
196
|
+
return SpeakerReviewResult(backend, speaker["meeting_slug"])
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _confirm_suggested_assignment(connection, speaker, backend: EmbeddingBackend | None) -> SpeakerReviewResult:
|
|
200
|
+
suggested_person_id = speaker.get("suggested_person_id")
|
|
201
|
+
suggested_person_name = speaker.get("suggested_person_name")
|
|
202
|
+
if not suggested_person_id:
|
|
203
|
+
console.print("No suggested person available for this speaker.")
|
|
204
|
+
return SpeakerReviewResult(backend)
|
|
205
|
+
|
|
206
|
+
backend = backend or _try_embedding_backend()
|
|
207
|
+
try:
|
|
208
|
+
result = create_voice_identity_from_speaker(connection, speaker["id"], suggested_person_id, backend=backend)
|
|
209
|
+
except ValueError as exc:
|
|
210
|
+
console.print(f"Could not create voice sample ({exc}); confirming assignment only.")
|
|
211
|
+
assignment = confirm_speaker_assignment(connection, speaker["id"])
|
|
212
|
+
console.print(f"Confirmed meeting speaker as {assignment['name']}.")
|
|
213
|
+
return SpeakerReviewResult(backend, speaker["meeting_slug"])
|
|
214
|
+
|
|
215
|
+
console.print(f"Confirmed meeting speaker as {suggested_person_name or result.person_name}.")
|
|
216
|
+
console.print(f"Voice sample: {result.voice_sample.audio_path}")
|
|
217
|
+
return SpeakerReviewResult(backend, speaker["meeting_slug"])
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _refresh_reviewed_meetings(connection, changed_meetings: set[str], backend: EmbeddingBackend | None) -> None:
|
|
221
|
+
if not changed_meetings:
|
|
222
|
+
return
|
|
223
|
+
refresh_meetings = _speaker_review_follow_up(connection, changed_meetings)
|
|
224
|
+
if not refresh_meetings:
|
|
225
|
+
return
|
|
226
|
+
config = load_config()
|
|
227
|
+
for meeting_slug in sorted(refresh_meetings):
|
|
228
|
+
result = refresh_meeting(
|
|
229
|
+
connection,
|
|
230
|
+
meeting_slug,
|
|
231
|
+
config,
|
|
232
|
+
embedding_backend=backend,
|
|
233
|
+
progress=lambda message: console.print(f"-> {message}"),
|
|
234
|
+
)
|
|
235
|
+
console.print(f"Refreshed {result.meeting.slug}")
|
|
236
|
+
console.print(f"Transcript: {result.export.transcript_path}")
|
|
237
|
+
console.print(f"Analysis: {result.export.analysis_path}")
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _speaker_review_follow_up(connection, changed_meetings: set[str]) -> set[str]:
|
|
241
|
+
console.print(f"Speaker review changed {len(changed_meetings)} meeting(s).")
|
|
242
|
+
while True:
|
|
243
|
+
action = _select_speaker_review_follow_up_action()
|
|
244
|
+
if action == "a":
|
|
245
|
+
return set(changed_meetings)
|
|
246
|
+
if action == "g":
|
|
247
|
+
return _speaker_review_global_follow_up(connection, changed_meetings)
|
|
248
|
+
if action == "n":
|
|
249
|
+
console.print("Refresh skipped. You can run refresh later.")
|
|
250
|
+
return set()
|
|
251
|
+
console.print("Choose an available follow-up action.")
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _speaker_review_global_follow_up(connection, changed_meetings: set[str]) -> set[str]:
|
|
255
|
+
results = rerun_speaker_matching_for_meetings(connection)
|
|
256
|
+
refreshed = {result["meeting_slug"] for result in results if result["match_count"]}
|
|
257
|
+
if not refreshed:
|
|
258
|
+
console.print("No new speaker matches found in other meetings.")
|
|
259
|
+
return set(changed_meetings)
|
|
260
|
+
console.print(f"Refreshed speaker matching with new matches: {len(refreshed)}")
|
|
261
|
+
return set(changed_meetings) | refreshed
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _try_embedding_backend() -> EmbeddingBackend | None:
|
|
265
|
+
try:
|
|
266
|
+
return PyannoteEmbeddingBackend()
|
|
267
|
+
except RuntimeError as exc:
|
|
268
|
+
console.print(f"Voice sample saved without embedding ({exc})")
|
|
269
|
+
return None
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _select_person(connection) -> Person | None:
|
|
273
|
+
people = list_people(connection)
|
|
274
|
+
if not people:
|
|
275
|
+
console.print("No known people found. Create a known person instead.")
|
|
276
|
+
return None
|
|
277
|
+
|
|
278
|
+
choices = [
|
|
279
|
+
MenuChoice(str(index) if index <= 9 else None, person.display_name, person.id)
|
|
280
|
+
for index, person in enumerate(people, start=1)
|
|
281
|
+
]
|
|
282
|
+
choices.append(MenuChoice("c", "Cancel", None))
|
|
283
|
+
selected_person_id = select_menu("Known person", choices)
|
|
284
|
+
if selected_person_id is None:
|
|
285
|
+
return None
|
|
286
|
+
return next(person for person in people if person.id == selected_person_id)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _select_speaker_review_action(clip_path: Path | None, can_confirm: bool = False) -> str:
|
|
290
|
+
choices = []
|
|
291
|
+
if clip_path is not None:
|
|
292
|
+
choices.append(MenuChoice("p", "Play clip", None, playback_path=clip_path))
|
|
293
|
+
if can_confirm:
|
|
294
|
+
choices.append(MenuChoice("v", "Confirm suggested person", "v"))
|
|
295
|
+
choices.extend(
|
|
296
|
+
[
|
|
297
|
+
MenuChoice("a", "Assign with voice sample", "a"),
|
|
298
|
+
MenuChoice("n", "Assign only", "n"),
|
|
299
|
+
MenuChoice("c", "New known person with voice sample", "c"),
|
|
300
|
+
MenuChoice("o", "New known person only", "o"),
|
|
301
|
+
MenuChoice("i", "Ignore speaker forever", "i"),
|
|
302
|
+
MenuChoice("s", "Skip this time", "s"),
|
|
303
|
+
MenuChoice("q", "Quit review", "q"),
|
|
304
|
+
]
|
|
305
|
+
)
|
|
306
|
+
return select_menu("Action", choices) or "q"
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _select_speaker_review_follow_up_action() -> str:
|
|
310
|
+
choices = [
|
|
311
|
+
MenuChoice("a", "Refresh affected meetings", "a"),
|
|
312
|
+
MenuChoice("g", "Refresh speaker matching globally", "g"),
|
|
313
|
+
MenuChoice("n", "Do nothing", "n"),
|
|
314
|
+
]
|
|
315
|
+
return select_menu("Next", choices) or "n"
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from time import sleep
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
from watchfiles import watch
|
|
11
|
+
|
|
12
|
+
from fly_on_the_wall.config import load_config
|
|
13
|
+
from fly_on_the_wall.db import database
|
|
14
|
+
from fly_on_the_wall.watch import (
|
|
15
|
+
DEFAULT_STABLE_AGE_SECONDS,
|
|
16
|
+
add_watch_folder,
|
|
17
|
+
list_watch_folders,
|
|
18
|
+
remove_watch_folder,
|
|
19
|
+
scan_watch_folders,
|
|
20
|
+
set_watch_folder_enabled,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
console = Console()
|
|
24
|
+
watch_app = typer.Typer(help="Process audio from watched folders.", no_args_is_help=True)
|
|
25
|
+
watch_folders_app = typer.Typer(help="Manage watched folders.", no_args_is_help=True)
|
|
26
|
+
watch_app.add_typer(watch_folders_app, name="folders")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@watch_app.command("scan")
|
|
30
|
+
def watch_scan(
|
|
31
|
+
stable_age_seconds: Annotated[
|
|
32
|
+
int,
|
|
33
|
+
typer.Option(
|
|
34
|
+
"--stable-age-seconds",
|
|
35
|
+
min=0,
|
|
36
|
+
help="Only process files unchanged for at least this many seconds.",
|
|
37
|
+
),
|
|
38
|
+
] = DEFAULT_STABLE_AGE_SECONDS,
|
|
39
|
+
) -> None:
|
|
40
|
+
"""Scan enabled watched folders once and process new audio files."""
|
|
41
|
+
_scan_watch_once(load_config(), stable_age_seconds)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@watch_app.command("run")
|
|
45
|
+
def watch_run(
|
|
46
|
+
interval_seconds: Annotated[
|
|
47
|
+
int,
|
|
48
|
+
typer.Option("--interval-seconds", min=1, help="Seconds between safety scans."),
|
|
49
|
+
] = 60,
|
|
50
|
+
stable_age_seconds: Annotated[
|
|
51
|
+
int,
|
|
52
|
+
typer.Option(
|
|
53
|
+
"--stable-age-seconds",
|
|
54
|
+
min=0,
|
|
55
|
+
help="Only process files unchanged for at least this many seconds.",
|
|
56
|
+
),
|
|
57
|
+
] = DEFAULT_STABLE_AGE_SECONDS,
|
|
58
|
+
) -> None:
|
|
59
|
+
"""Watch enabled folders and process new audio files as they appear."""
|
|
60
|
+
config = load_config()
|
|
61
|
+
folders = _enabled_watch_folders()
|
|
62
|
+
if not folders:
|
|
63
|
+
console.print("No enabled watch folders configured.")
|
|
64
|
+
console.print("Add one with: fow watch folders add <path>")
|
|
65
|
+
raise typer.Exit(code=1)
|
|
66
|
+
|
|
67
|
+
console.print("Watching folders for audio changes. Press Ctrl+C to stop.")
|
|
68
|
+
for path in [folder.path for folder in folders]:
|
|
69
|
+
console.print(f"- {path}")
|
|
70
|
+
|
|
71
|
+
_scan_watch_once(config, stable_age_seconds)
|
|
72
|
+
while True:
|
|
73
|
+
_watch_run_once(config, stable_age_seconds, interval_seconds)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@watch_folders_app.command("add")
|
|
77
|
+
def watch_folders_add(
|
|
78
|
+
path: Annotated[Path, typer.Argument(file_okay=False, dir_okay=True)],
|
|
79
|
+
name: Annotated[str | None, typer.Option("--name", "-n", help="Optional folder name.")] = None,
|
|
80
|
+
) -> None:
|
|
81
|
+
"""Add a folder to scan for audio files."""
|
|
82
|
+
with database() as connection:
|
|
83
|
+
try:
|
|
84
|
+
folder = add_watch_folder(connection, path, name)
|
|
85
|
+
except Exception as exc:
|
|
86
|
+
console.print(str(exc))
|
|
87
|
+
raise typer.Exit(code=1) from exc
|
|
88
|
+
console.print(f"Added watch folder {folder.path}")
|
|
89
|
+
if folder.name:
|
|
90
|
+
console.print(f"Name: {folder.name}")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@watch_folders_app.command("list")
|
|
94
|
+
def watch_folders_list() -> None:
|
|
95
|
+
"""List watched folders."""
|
|
96
|
+
with database() as connection:
|
|
97
|
+
folders = list_watch_folders(connection)
|
|
98
|
+
if not folders:
|
|
99
|
+
console.print("No watch folders configured.")
|
|
100
|
+
return
|
|
101
|
+
table = Table(title="Watch Folders")
|
|
102
|
+
table.add_column("ID")
|
|
103
|
+
table.add_column("Name")
|
|
104
|
+
table.add_column("Enabled")
|
|
105
|
+
table.add_column("Path")
|
|
106
|
+
for folder in folders:
|
|
107
|
+
table.add_row(
|
|
108
|
+
folder.id,
|
|
109
|
+
folder.name or "",
|
|
110
|
+
"yes" if folder.enabled else "no",
|
|
111
|
+
str(folder.path),
|
|
112
|
+
)
|
|
113
|
+
console.print(table)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@watch_folders_app.command("remove")
|
|
117
|
+
def watch_folders_remove(identifier: str) -> None:
|
|
118
|
+
"""Remove a watched folder by id, name, or path."""
|
|
119
|
+
with database() as connection:
|
|
120
|
+
folder = remove_watch_folder(connection, identifier)
|
|
121
|
+
if folder is None:
|
|
122
|
+
console.print(f"Watch folder not found: {identifier}")
|
|
123
|
+
raise typer.Exit(code=1)
|
|
124
|
+
console.print(f"Removed watch folder {folder.path}")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@watch_folders_app.command("enable")
|
|
128
|
+
def watch_folders_enable(identifier: str) -> None:
|
|
129
|
+
"""Enable a watched folder by id, name, or path."""
|
|
130
|
+
_set_watch_folder_enabled_command(identifier, True)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@watch_folders_app.command("disable")
|
|
134
|
+
def watch_folders_disable(identifier: str) -> None:
|
|
135
|
+
"""Disable a watched folder by id, name, or path."""
|
|
136
|
+
_set_watch_folder_enabled_command(identifier, False)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _watch_run_once(config, stable_age_seconds: int, interval_seconds: int) -> None:
|
|
140
|
+
existing_paths = _existing_watch_paths()
|
|
141
|
+
if not existing_paths:
|
|
142
|
+
console.print("No watched folders are currently mounted. Running safety scan.")
|
|
143
|
+
_scan_watch_once(config, stable_age_seconds)
|
|
144
|
+
sleep(interval_seconds)
|
|
145
|
+
return
|
|
146
|
+
|
|
147
|
+
changes = _watch_for_changes(existing_paths, interval_seconds)
|
|
148
|
+
if changes is None:
|
|
149
|
+
_scan_watch_once(config, stable_age_seconds)
|
|
150
|
+
return
|
|
151
|
+
|
|
152
|
+
_print_watch_changes(changes)
|
|
153
|
+
_scan_watch_once(config, stable_age_seconds)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _enabled_watch_folders():
|
|
157
|
+
with database() as connection:
|
|
158
|
+
return [folder for folder in list_watch_folders(connection) if folder.enabled]
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _existing_watch_paths() -> list[Path]:
|
|
162
|
+
return [folder.path for folder in _enabled_watch_folders() if folder.path.is_dir()]
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _watch_for_changes(paths: list[Path], interval_seconds: int):
|
|
166
|
+
try:
|
|
167
|
+
return next(
|
|
168
|
+
watch(
|
|
169
|
+
*paths,
|
|
170
|
+
recursive=True,
|
|
171
|
+
yield_on_timeout=True,
|
|
172
|
+
rust_timeout=interval_seconds * 1000,
|
|
173
|
+
)
|
|
174
|
+
)
|
|
175
|
+
except (OSError, RuntimeError) as exc:
|
|
176
|
+
console.print(f"Watch backend restarted after folder change: {exc}")
|
|
177
|
+
return None
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _print_watch_changes(changes) -> None:
|
|
181
|
+
if changes:
|
|
182
|
+
console.print(f"Detected {len(changes)} file change(s).")
|
|
183
|
+
return
|
|
184
|
+
console.print("Running periodic safety scan.")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _scan_watch_once(config, stable_age_seconds: int) -> None:
|
|
188
|
+
with database() as connection:
|
|
189
|
+
result = scan_watch_folders(
|
|
190
|
+
connection,
|
|
191
|
+
config,
|
|
192
|
+
stable_age_seconds=stable_age_seconds,
|
|
193
|
+
progress=lambda message: console.print(f"-> {message}"),
|
|
194
|
+
)
|
|
195
|
+
console.print(
|
|
196
|
+
f"Watch scan complete: {result.processed} processed, "
|
|
197
|
+
f"{result.ignored} ignored, {result.skipped} skipped, "
|
|
198
|
+
f"{result.failed} failed, {result.seen} seen."
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _set_watch_folder_enabled_command(identifier: str, enabled: bool) -> None:
|
|
203
|
+
with database() as connection:
|
|
204
|
+
folder = set_watch_folder_enabled(connection, identifier, enabled)
|
|
205
|
+
if folder is None:
|
|
206
|
+
console.print(f"Watch folder not found: {identifier}")
|
|
207
|
+
raise typer.Exit(code=1)
|
|
208
|
+
state = "Enabled" if enabled else "Disabled"
|
|
209
|
+
console.print(f"{state} watch folder {folder.path}")
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Literal
|
|
6
|
+
|
|
7
|
+
import yaml
|
|
8
|
+
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
|
9
|
+
|
|
10
|
+
APP_DIR_NAME = "fly-on-the-wall"
|
|
11
|
+
CONFIG_FILE_NAME = "config.yaml"
|
|
12
|
+
GLOSSARY_FILE_NAME = "glossary.yaml"
|
|
13
|
+
|
|
14
|
+
ProviderName = Literal["elevenlabs", "openai"]
|
|
15
|
+
CleanupMode = Literal["off", "deterministic", "light"]
|
|
16
|
+
|
|
17
|
+
API_KEY_ENV_VARS: dict[str, str] = {
|
|
18
|
+
"elevenlabs": "ELEVENLABS_API_KEY",
|
|
19
|
+
"openai": "OPENAI_API_KEY",
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ConfigError(RuntimeError):
|
|
24
|
+
"""Raised when the application config cannot be loaded."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ConfidenceThresholds(BaseModel):
|
|
28
|
+
model_config = ConfigDict(extra="forbid")
|
|
29
|
+
|
|
30
|
+
named: float = Field(default=0.78, ge=0.0, le=1.0)
|
|
31
|
+
uncertain: float = Field(default=0.62, ge=0.0, le=1.0)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class AppConfig(BaseModel):
|
|
35
|
+
model_config = ConfigDict(extra="forbid")
|
|
36
|
+
|
|
37
|
+
default_transcription_provider: ProviderName = "elevenlabs"
|
|
38
|
+
language: str = "sv"
|
|
39
|
+
export_destination: Path | None = None
|
|
40
|
+
confidence_thresholds: ConfidenceThresholds = Field(default_factory=ConfidenceThresholds)
|
|
41
|
+
cleanup_mode: CleanupMode = "light"
|
|
42
|
+
glossary_path: Path | None = None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def config_dir() -> Path:
|
|
46
|
+
xdg_config_home = os.environ.get("XDG_CONFIG_HOME")
|
|
47
|
+
if xdg_config_home:
|
|
48
|
+
return Path(xdg_config_home).expanduser() / APP_DIR_NAME
|
|
49
|
+
return Path.home() / ".config" / APP_DIR_NAME
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def default_config_path() -> Path:
|
|
53
|
+
return config_dir() / CONFIG_FILE_NAME
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def default_glossary_path() -> Path:
|
|
57
|
+
return config_dir() / GLOSSARY_FILE_NAME
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def load_config(path: Path | None = None) -> AppConfig:
|
|
61
|
+
config_path = path or default_config_path()
|
|
62
|
+
data = _read_yaml_mapping(config_path)
|
|
63
|
+
|
|
64
|
+
if "glossary_path" not in data:
|
|
65
|
+
data["glossary_path"] = default_glossary_path()
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
return AppConfig.model_validate(data)
|
|
69
|
+
except ValidationError as exc:
|
|
70
|
+
raise ConfigError(f"Invalid config at {config_path}: {exc}") from exc
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def get_api_key(provider: str) -> str | None:
|
|
74
|
+
from fly_on_the_wall.secrets import get_api_key as get_secret_api_key
|
|
75
|
+
|
|
76
|
+
return get_secret_api_key(provider)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _read_yaml_mapping(path: Path) -> dict[str, Any]:
|
|
80
|
+
if not path.exists():
|
|
81
|
+
return {}
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
content = yaml.safe_load(path.read_text())
|
|
85
|
+
except yaml.YAMLError as exc:
|
|
86
|
+
raise ConfigError(f"Invalid YAML at {path}: {exc}") from exc
|
|
87
|
+
|
|
88
|
+
if content is None:
|
|
89
|
+
return {}
|
|
90
|
+
if not isinstance(content, dict):
|
|
91
|
+
raise ConfigError(f"Config at {path} must be a YAML mapping.")
|
|
92
|
+
return content
|