GameSentenceMiner 2.0.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.
@@ -0,0 +1,128 @@
1
+ import asyncio
2
+ import threading
3
+ import time
4
+ from collections import OrderedDict
5
+ from datetime import datetime
6
+
7
+ import pyperclip
8
+ import websockets
9
+
10
+ from . import util
11
+ from .configuration import *
12
+ from .configuration import get_config, logger
13
+ from .util import remove_html_tags
14
+ from difflib import SequenceMatcher
15
+
16
+
17
+ previous_line = ''
18
+ previous_line_time = datetime.now()
19
+
20
+ line_history = OrderedDict()
21
+ reconnecting = False
22
+
23
+
24
+ class ClipboardMonitor(threading.Thread):
25
+
26
+ def __init__(self):
27
+ threading.Thread.__init__(self)
28
+ self.daemon = True
29
+
30
+ def run(self):
31
+ global previous_line_time, previous_line, line_history
32
+
33
+ # Initial clipboard content
34
+ previous_line = pyperclip.paste()
35
+
36
+ while True:
37
+ current_clipboard = pyperclip.paste()
38
+
39
+ if current_clipboard != previous_line:
40
+ previous_line = current_clipboard
41
+ previous_line_time = datetime.now()
42
+ line_history[previous_line] = previous_line_time
43
+ util.use_previous_audio = False
44
+
45
+ time.sleep(0.05)
46
+
47
+
48
+ async def listen_websocket():
49
+ global previous_line, previous_line_time, line_history, reconnecting
50
+ while True:
51
+ try:
52
+ async with websockets.connect(f'ws://{get_config().general.websocket_uri}', ping_interval=None) as websocket:
53
+ if reconnecting:
54
+ print(f"Texthooker WebSocket connected Successfully!")
55
+ reconnecting = False
56
+ while True:
57
+ message = await websocket.recv()
58
+
59
+ try:
60
+ data = json.loads(message)
61
+ if "sentence" in data:
62
+ current_clipboard = data["sentence"]
63
+ except json.JSONDecodeError:
64
+ current_clipboard = message
65
+
66
+ if current_clipboard != previous_line:
67
+ previous_line = current_clipboard
68
+ previous_line_time = datetime.now()
69
+ line_history[previous_line] = previous_line_time
70
+ util.use_previous_audio = False
71
+
72
+ except (websockets.ConnectionClosed, ConnectionError) as e:
73
+ if not reconnecting:
74
+ print(f"Texthooker WebSocket connection lost: {e}. Attempting to Reconnect...")
75
+ reconnecting = True
76
+ await asyncio.sleep(5)
77
+
78
+
79
+ def reset_line_hotkey_pressed():
80
+ global previous_line_time
81
+ logger.info("LINE RESET HOTKEY PRESSED")
82
+ previous_line_time = datetime.now()
83
+ line_history[previous_line] = previous_line_time
84
+ util.use_previous_audio = False
85
+
86
+
87
+ def run_websocket_listener():
88
+ asyncio.run(listen_websocket())
89
+
90
+
91
+ def start_text_monitor():
92
+ if get_config().general.use_websocket:
93
+ text_thread = threading.Thread(target=run_websocket_listener, daemon=True)
94
+ else:
95
+ text_thread = ClipboardMonitor()
96
+ text_thread.start()
97
+
98
+
99
+ def get_line_timing(last_note):
100
+ def similar(a, b):
101
+ return SequenceMatcher(None, a, b).ratio()
102
+
103
+ if not last_note:
104
+ return previous_line_time, 0
105
+
106
+ line_time = previous_line_time
107
+ next_line = 0
108
+ prev_clip_time = 0
109
+
110
+ try:
111
+ sentence = last_note['fields'][get_config().anki.sentence_field]['value']
112
+ if sentence:
113
+ for i, (line, clip_time) in enumerate(reversed(line_history.items())):
114
+ similarity = similar(remove_html_tags(sentence), line)
115
+ if similarity >= 0.60: # 80% similarity threshold
116
+ line_time = clip_time
117
+ next_line = prev_clip_time
118
+ break
119
+ prev_clip_time = clip_time
120
+ except Exception as e:
121
+ logger.error(f"Using Default clipboard/websocket timing - reason: {e}")
122
+
123
+ return line_time, next_line
124
+
125
+
126
+ def get_last_two_sentences():
127
+ lines = list(line_history.items())
128
+ return lines[-1][0] if lines else '', lines[-2][0] if len(lines) > 1 else ''
@@ -0,0 +1,385 @@
1
+ import shutil
2
+ import signal
3
+ import sys
4
+ import tempfile
5
+ import time
6
+
7
+ import keyboard
8
+ import psutil
9
+ import win32api
10
+ from PIL import Image, ImageDraw
11
+ from pystray import Icon, Menu, MenuItem
12
+ from watchdog.events import FileSystemEventHandler
13
+ from watchdog.observers import Observer
14
+
15
+ from . import anki
16
+ from . import config_gui
17
+ from . import configuration
18
+ from . import ffmpeg
19
+ from . import gametext
20
+ from . import notification
21
+ from . import obs
22
+ from . import util
23
+ from .vad import vosk_helper, silero_trim, whisper_helper
24
+ from .configuration import *
25
+ from .ffmpeg import get_audio_and_trim
26
+ from .gametext import get_line_timing
27
+ from .util import *
28
+
29
+ config_pids = []
30
+ settings_window: config_gui.ConfigApp = None
31
+ obs_paused = False
32
+ icon: Icon
33
+ menu: Menu
34
+
35
+
36
+ class VideoToAudioHandler(FileSystemEventHandler):
37
+ def on_created(self, event):
38
+ if event.is_directory or "Replay" not in event.src_path:
39
+ return
40
+ if event.src_path.endswith(".mkv") or event.src_path.endswith(".mp4"): # Adjust based on your OBS output format
41
+ logger.info(f"MKV {event.src_path} FOUND, RUNNING LOGIC")
42
+ time.sleep(.5) # Small Sleep to allow for replay to be fully written
43
+ self.convert_to_audio(event.src_path)
44
+
45
+ @staticmethod
46
+ def convert_to_audio(video_path):
47
+ try:
48
+ with util.lock:
49
+ if get_config().obs.minimum_replay_size and not ffmpeg.is_video_big_enough(video_path,
50
+ get_config().obs.minimum_replay_size):
51
+ notification.send_check_obs_notification(reason="Video may be empty, check scene in OBS.")
52
+ logger.error(
53
+ f"Video was unusually small, potentially empty! Check OBS for Correct Scene Settings! Path: {video_path}")
54
+ return
55
+ util.use_previous_audio = True
56
+ last_note = None
57
+ if get_config().anki.update_anki:
58
+ last_note = anki.get_last_anki_card()
59
+ if get_config().features.backfill_audio:
60
+ last_note = anki.get_cards_by_sentence(gametext.previous_line)
61
+ line_time, next_line_time = get_line_timing(last_note)
62
+ ss_timing = 0
63
+ if line_time and next_line_time:
64
+ ss_timing = ffmpeg.get_screenshot_time(video_path, line_time)
65
+ if last_note:
66
+ logger.debug(json.dumps(last_note))
67
+
68
+ note = anki.get_initial_card_info(last_note)
69
+
70
+ tango = last_note['fields'][get_config().anki.word_field]['value'] if last_note else ''
71
+
72
+ if get_config().anki.sentence_audio_field:
73
+ final_audio_output, should_update_audio, vad_trimmed_audio = VideoToAudioHandler.get_audio(
74
+ line_time,
75
+ next_line_time,
76
+ video_path)
77
+ else:
78
+ final_audio_output = ""
79
+ should_update_audio = False
80
+ vad_trimmed_audio = ""
81
+ logger.info("No SentenceAudio Field in config, skipping audio processing!")
82
+ try:
83
+ # Only update sentenceaudio if it's not present. Want to avoid accidentally overwriting sentence audio
84
+ try:
85
+ if get_config().anki.update_anki and last_note:
86
+ anki.update_anki_card(last_note, note, audio_path=final_audio_output, video_path=video_path,
87
+ tango=tango,
88
+ should_update_audio=should_update_audio,
89
+ ss_time=ss_timing)
90
+ elif get_config().features.notify_on_update and should_update_audio:
91
+ notification.send_audio_generated_notification(vad_trimmed_audio)
92
+ except Exception as e:
93
+ logger.error(f"Card failed to update! Maybe it was removed? {e}")
94
+ except FileNotFoundError as f:
95
+ print(f)
96
+ print("Something went wrong with processing, anki card not updated")
97
+ except Exception as e:
98
+ logger.error(f"Some error was hit catching to allow further work to be done: {e}")
99
+ if get_config().paths.remove_video and os.path.exists(video_path):
100
+ os.remove(video_path) # Optionally remove the video after conversion
101
+ if get_config().paths.remove_audio and os.path.exists(vad_trimmed_audio):
102
+ os.remove(vad_trimmed_audio) # Optionally remove the screenshot after conversion
103
+
104
+ @staticmethod
105
+ def get_audio(line_time, next_line_time, video_path):
106
+ trimmed_audio = get_audio_and_trim(video_path, line_time, next_line_time)
107
+ vad_trimmed_audio = make_unique_file_name(
108
+ f"{os.path.abspath(configuration.temp_directory)}/{obs.get_current_game(sanitize=True)}.{get_config().audio.extension}")
109
+ final_audio_output = make_unique_file_name(
110
+ f"{get_config().paths.audio_destination}{obs.get_current_game(sanitize=True)}.{get_config().audio.extension}")
111
+ should_update_audio = True
112
+ if get_config().vad.do_vad_postprocessing:
113
+ match get_config().vad.selected_vad_model:
114
+ case configuration.SILERO:
115
+ should_update_audio = silero_trim.process_audio_with_silero(trimmed_audio, vad_trimmed_audio)
116
+ case configuration.VOSK:
117
+ should_update_audio = vosk_helper.process_audio_with_vosk(trimmed_audio, vad_trimmed_audio)
118
+ case configuration.WHISPER:
119
+ should_update_audio = whisper_helper.process_audio_with_whisper(trimmed_audio,
120
+ vad_trimmed_audio)
121
+ if not should_update_audio:
122
+ match get_config().vad.backup_vad_model:
123
+ case configuration.OFF:
124
+ pass
125
+ case configuration.SILERO:
126
+ should_update_audio = silero_trim.process_audio_with_silero(trimmed_audio,
127
+ vad_trimmed_audio)
128
+ case configuration.VOSK:
129
+ should_update_audio = vosk_helper.process_audio_with_vosk(trimmed_audio, vad_trimmed_audio)
130
+ case configuration.WHISPER:
131
+ should_update_audio = whisper_helper.process_audio_with_whisper(trimmed_audio,
132
+ vad_trimmed_audio)
133
+ if get_config().audio.ffmpeg_reencode_options and os.path.exists(vad_trimmed_audio):
134
+ ffmpeg.reencode_file_with_user_config(vad_trimmed_audio, final_audio_output,
135
+ get_config().audio.ffmpeg_reencode_options)
136
+ elif os.path.exists(vad_trimmed_audio):
137
+ os.replace(vad_trimmed_audio, final_audio_output)
138
+ return final_audio_output, should_update_audio, vad_trimmed_audio
139
+
140
+
141
+ def initialize(reloading=False):
142
+ if not reloading:
143
+ if get_config().obs.enabled:
144
+ obs.connect_to_obs(start_replay=True)
145
+ anki.start_monitoring_anki()
146
+ if get_config().general.open_config_on_startup:
147
+ proc = subprocess.Popen([sys.executable, "config_gui.py"])
148
+ config_pids.append(proc.pid)
149
+ gametext.start_text_monitor()
150
+ if not os.path.exists(get_config().paths.folder_to_watch):
151
+ os.mkdir(get_config().paths.folder_to_watch)
152
+ if not os.path.exists(get_config().paths.screenshot_destination):
153
+ os.mkdir(get_config().paths.screenshot_destination)
154
+ if not os.path.exists(get_config().paths.audio_destination):
155
+ os.mkdir(get_config().paths.audio_destination)
156
+ if not os.path.exists("../temp_files"):
157
+ os.mkdir("../temp_files")
158
+ else:
159
+ for filename in os.scandir('../temp_files'):
160
+ file_path = os.path.join('../temp_files', filename.name)
161
+ if filename.is_file() or filename.is_symlink():
162
+ os.remove(file_path)
163
+ elif filename.is_dir():
164
+ shutil.rmtree(file_path)
165
+ if get_config().vad.do_vad_postprocessing:
166
+ if VOSK in (get_config().vad.backup_vad_model, get_config().vad.selected_vad_model):
167
+ vosk_helper.get_vosk_model()
168
+ if WHISPER in (get_config().vad.backup_vad_model, get_config().vad.selected_vad_model):
169
+ whisper_helper.initialize_whisper_model()
170
+
171
+
172
+ def register_hotkeys():
173
+ keyboard.add_hotkey(get_config().hotkeys.reset_line, gametext.reset_line_hotkey_pressed)
174
+ keyboard.add_hotkey(get_config().hotkeys.take_screenshot, get_screenshot)
175
+
176
+
177
+ def get_screenshot():
178
+ try:
179
+ image = obs.get_screenshot()
180
+ time.sleep(2) # Wait for ss to save
181
+ if not image:
182
+ raise Exception("Failed to get Screenshot from OBS")
183
+ encoded_image = ffmpeg.process_image(image)
184
+ if get_config().anki.update_anki and get_config().screenshot.screenshot_hotkey_updates_anki:
185
+ last_note = anki.get_last_anki_card()
186
+ if last_note:
187
+ logger.debug(json.dumps(last_note))
188
+ if get_config().features.backfill_audio:
189
+ last_note = anki.get_cards_by_sentence(gametext.previous_line)
190
+ if last_note:
191
+ anki.add_image_to_card(last_note, encoded_image)
192
+ notification.send_screenshot_updated(last_note['fields'][get_config().anki.word_field]['value'])
193
+ if get_config().features.open_anki_edit:
194
+ notification.open_anki_card(last_note['noteId'])
195
+ else:
196
+ notification.send_screenshot_saved(encoded_image)
197
+ else:
198
+ notification.send_screenshot_saved(encoded_image)
199
+ except Exception as e:
200
+ logger.error(f"Failed to get Screenshot {e}")
201
+
202
+
203
+ def create_image():
204
+ """Create a simple pickaxe icon."""
205
+ width, height = 64, 64
206
+ image = Image.new("RGBA", (width, height), (0, 0, 0, 0)) # Transparent background
207
+ draw = ImageDraw.Draw(image)
208
+
209
+ # Handle (rectangle)
210
+ handle_color = (139, 69, 19) # Brown color
211
+ draw.rectangle([(30, 15), (34, 50)], fill=handle_color)
212
+
213
+ # Blade (triangle-like shape)
214
+ blade_color = (192, 192, 192) # Silver color
215
+ draw.polygon([(15, 15), (49, 15), (32, 5)], fill=blade_color)
216
+
217
+ return image
218
+
219
+
220
+ def open_settings():
221
+ obs.update_current_game()
222
+ settings_window.show()
223
+
224
+
225
+ def open_log():
226
+ """Function to handle opening log."""
227
+ """Open log file with the default application."""
228
+ log_file_path = "../../gamesentenceminer.log"
229
+ if not os.path.exists(log_file_path):
230
+ print("Log file not found!")
231
+ return
232
+
233
+ if sys.platform.startswith("win"): # Windows
234
+ os.startfile(log_file_path)
235
+ elif sys.platform.startswith("darwin"): # macOS
236
+ subprocess.call(["open", log_file_path])
237
+ elif sys.platform.startswith("linux"): # Linux
238
+ subprocess.call(["xdg-open", log_file_path])
239
+ else:
240
+ print("Unsupported platform!")
241
+ print("Log opened.")
242
+
243
+
244
+ def exit_program(icon, item):
245
+ """Exit the application."""
246
+ print("Exiting...")
247
+ icon.stop()
248
+ cleanup()
249
+
250
+
251
+ def play_pause(icon, item):
252
+ global obs_paused, menu
253
+ if obs_paused:
254
+ obs.start_replay_buffer()
255
+ else:
256
+ obs.stop_replay_buffer()
257
+
258
+ obs_paused = not obs_paused
259
+ update_icon()
260
+
261
+ def get_obs_icon_text():
262
+ return "Pause OBS" if obs_paused else "Resume OBS"
263
+
264
+
265
+ def update_icon():
266
+ global menu, icon
267
+ # Recreate the menu with the updated button text
268
+ profile_menu = Menu(
269
+ *[MenuItem(("Active: " if profile == get_master_config().current_profile else "") + profile, switch_profile) for profile in
270
+ get_master_config().get_all_profile_names()]
271
+ )
272
+
273
+ menu = Menu(
274
+ MenuItem("Open Settings", open_settings),
275
+ MenuItem("Open Log", open_log),
276
+ MenuItem(get_obs_icon_text(), play_pause),
277
+ MenuItem("Switch Profile", profile_menu),
278
+ MenuItem("Exit", exit_program)
279
+ )
280
+
281
+ icon.menu = menu
282
+ icon.update_menu()
283
+
284
+ def switch_profile(icon, item):
285
+ if "Active:" in item.text:
286
+ logger.error("You cannot switch to the currently active profile!")
287
+ return
288
+ logger.info(f"Switching to profile: {item.text}")
289
+ get_master_config().current_profile = item.text
290
+ switch_profile_and_save(item.text)
291
+ settings_window.reload_settings()
292
+ update_icon()
293
+
294
+
295
+ def run_tray():
296
+ global menu, icon
297
+
298
+ profile_menu = Menu(
299
+ *[MenuItem(("Active: " if profile == get_master_config().current_profile else "") + profile, switch_profile) for
300
+ profile in
301
+ get_master_config().get_all_profile_names()]
302
+ )
303
+
304
+ menu = Menu(
305
+ MenuItem("Open Settings", open_settings),
306
+ MenuItem("Open Log", open_log),
307
+ MenuItem(get_obs_icon_text(), play_pause),
308
+ MenuItem("Switch Profile", profile_menu),
309
+ MenuItem("Exit", exit_program)
310
+ )
311
+
312
+ icon = Icon("TrayApp", create_image(), "Game Sentence Miner", menu)
313
+ icon.run()
314
+
315
+
316
+ def cleanup():
317
+ logger.info("Performing cleanup...")
318
+ util.keep_running = False
319
+
320
+ for pid in config_pids:
321
+ try:
322
+ p = psutil.Process(pid)
323
+ p.terminate() # Gracefully terminate the process
324
+ except psutil.NoSuchProcess:
325
+ logger.info("Config process already closed.")
326
+ except Exception as e:
327
+ logger.error(f"Error terminating process {pid}: {e}")
328
+
329
+ if get_config().obs.enabled:
330
+ if get_config().obs.start_buffer:
331
+ obs.stop_replay_buffer()
332
+ obs.disconnect_from_obs()
333
+ settings_window.window.destroy()
334
+ logger.info("Cleanup complete.")
335
+
336
+
337
+ def handle_exit():
338
+ """Signal handler for graceful termination."""
339
+
340
+ def _handle_exit(signum):
341
+ logger.info(f"Received signal {signum}. Exiting gracefully...")
342
+ cleanup()
343
+ sys.exit(0)
344
+
345
+ return _handle_exit
346
+
347
+
348
+ def main(reloading=False, do_config_input=True):
349
+ global settings_window
350
+ logger.info("Script started.")
351
+ initialize(reloading)
352
+ with tempfile.TemporaryDirectory(dir="../temp_files") as temp_dir:
353
+ configuration.temp_directory = temp_dir
354
+ event_handler = VideoToAudioHandler()
355
+ observer = Observer()
356
+ observer.schedule(event_handler, get_config().paths.folder_to_watch, recursive=False)
357
+ observer.start()
358
+
359
+ logger.info("Script Initialized. Happy Mining!")
360
+ if not is_linux():
361
+ register_hotkeys()
362
+
363
+ # Register signal handlers for graceful shutdown
364
+ signal.signal(signal.SIGTERM, handle_exit()) # Handle `kill` commands
365
+ signal.signal(signal.SIGINT, handle_exit()) # Handle Ctrl+C
366
+ win32api.SetConsoleCtrlHandler(handle_exit())
367
+
368
+ util.run_new_thread(run_tray)
369
+
370
+ try:
371
+ settings_window = config_gui.ConfigApp()
372
+ settings_window.add_save_hook(update_icon)
373
+ settings_window.window.mainloop()
374
+ except KeyboardInterrupt:
375
+ cleanup()
376
+
377
+ try:
378
+ observer.stop()
379
+ observer.join()
380
+ except Exception as e:
381
+ logger.error(f"Error stopping observer: {e}")
382
+
383
+
384
+ if __name__ == "__main__":
385
+ main()
@@ -0,0 +1,84 @@
1
+ from dataclasses import dataclass
2
+ from typing import Optional, List
3
+
4
+ from dataclasses_json import dataclass_json
5
+
6
+
7
+ # OBS
8
+ @dataclass_json
9
+ @dataclass
10
+ class SceneInfo:
11
+ currentProgramSceneName: str
12
+ currentProgramSceneUuid: str
13
+ sceneName: str
14
+ sceneUuid: str
15
+
16
+
17
+ @dataclass_json
18
+ @dataclass
19
+ class SceneItemTransform:
20
+ alignment: int
21
+ boundsAlignment: int
22
+ boundsHeight: float
23
+ boundsType: str
24
+ boundsWidth: float
25
+ cropBottom: int
26
+ cropLeft: int
27
+ cropRight: int
28
+ cropToBounds: bool
29
+ cropTop: int
30
+ height: float
31
+ positionX: float
32
+ positionY: float
33
+ rotation: float
34
+ scaleX: float
35
+ scaleY: float
36
+ sourceHeight: float
37
+ sourceWidth: float
38
+ width: float
39
+
40
+
41
+ @dataclass_json
42
+ @dataclass
43
+ class SceneItem:
44
+ inputKind: str
45
+ isGroup: Optional[bool]
46
+ sceneItemBlendMode: str
47
+ sceneItemEnabled: bool
48
+ sceneItemId: int
49
+ sceneItemIndex: int
50
+ sceneItemLocked: bool
51
+ sceneItemTransform: SceneItemTransform
52
+ sourceName: str
53
+ sourceType: str
54
+ sourceUuid: str
55
+
56
+ # def __init__(self, **kwargs):
57
+ # self.inputKind = kwargs['inputKind']
58
+ # self.isGroup = kwargs['isGroup']
59
+ # self.sceneItemBlendMode = kwargs['sceneItemBlendMode']
60
+ # self.sceneItemEnabled = kwargs['sceneItemEnabled']
61
+ # self.sceneItemId = kwargs['sceneItemId']
62
+ # self.sceneItemIndex = kwargs['sceneItemIndex']
63
+ # self.sceneItemLocked = kwargs['sceneItemLocked']
64
+ # self.sceneItemTransform = SceneItemTransform(**kwargs['sceneItemTransform'])
65
+ # self.sourceName = kwargs['sourceName']
66
+ # self.sourceType = kwargs['sourceType']
67
+ # self.sourceUuid = kwargs['sourceUuid']
68
+
69
+
70
+ @dataclass_json
71
+ @dataclass
72
+ class SceneItemsResponse:
73
+ sceneItems: List[SceneItem]
74
+
75
+ # def __init__(self, **kwargs):
76
+ # self.sceneItems = [SceneItem(**item) for item in kwargs['sceneItems']]
77
+
78
+ #
79
+ # @dataclass_json
80
+ # @dataclass
81
+ # class SourceActive:
82
+ # videoActive: bool
83
+ # videoShowing: bool
84
+
@@ -0,0 +1,69 @@
1
+ import requests
2
+ from plyer import notification
3
+
4
+
5
+ def open_anki_card(note_id):
6
+ url = "http://localhost:8765"
7
+ headers = {'Content-Type': 'application/json'}
8
+
9
+ data = {
10
+ "action": "guiEditNote",
11
+ "version": 6,
12
+ "params": {
13
+ "note": note_id
14
+ }
15
+ }
16
+
17
+ try:
18
+ response = requests.post(url, json=data, headers=headers)
19
+ if response.status_code == 200:
20
+ print(f"Opened Anki note with ID {note_id}")
21
+ else:
22
+ print(f"Failed to open Anki note with ID {note_id}")
23
+ except Exception as e:
24
+ print(f"Error connecting to AnkiConnect: {e}")
25
+
26
+
27
+ def send_notification(tango):
28
+ notification.notify(
29
+ title="Anki Card Updated",
30
+ message=f"Audio and/or Screenshot added to note: {tango}",
31
+ app_name="GameSentenceMiner",
32
+ timeout=5 # Notification disappears after 5 seconds
33
+ )
34
+
35
+
36
+ def send_screenshot_updated(tango):
37
+ notification.notify(
38
+ title="Anki Card Updated",
39
+ message=f"Screenshot updated on note: {tango}",
40
+ app_name="GameSentenceMiner",
41
+ timeout=5 # Notification disappears after 5 seconds
42
+ )
43
+
44
+
45
+ def send_screenshot_saved(path):
46
+ notification.notify(
47
+ title="Screenshot Saved",
48
+ message=f"Screenshot saved to : {path}",
49
+ app_name="GameSentenceMiner",
50
+ timeout=5 # Notification disappears after 5 seconds
51
+ )
52
+
53
+
54
+ def send_audio_generated_notification(audio_path):
55
+ notification.notify(
56
+ title="Audio Trimmed",
57
+ message=f"Audio Trimmed and placed at {audio_path}",
58
+ app_name="VideoGameMiner",
59
+ timeout=5 # Notification disappears after 5 seconds
60
+ )
61
+
62
+
63
+ def send_check_obs_notification(reason):
64
+ notification.notify(
65
+ title="OBS Replay Invalid",
66
+ message=f"Check OBS Settings! Reason: {reason}",
67
+ app_name="GameSentenceMiner",
68
+ timeout=5 # Notification disappears after 5 seconds
69
+ )