GameSentenceMiner 2.14.9__py3-none-any.whl → 2.14.10__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.
Files changed (62) hide show
  1. GameSentenceMiner/ai/__init__.py +0 -0
  2. GameSentenceMiner/ai/ai_prompting.py +473 -0
  3. GameSentenceMiner/ocr/__init__.py +0 -0
  4. GameSentenceMiner/ocr/gsm_ocr_config.py +174 -0
  5. GameSentenceMiner/ocr/ocrconfig.py +129 -0
  6. GameSentenceMiner/ocr/owocr_area_selector.py +629 -0
  7. GameSentenceMiner/ocr/owocr_helper.py +638 -0
  8. GameSentenceMiner/ocr/ss_picker.py +140 -0
  9. GameSentenceMiner/owocr/owocr/__init__.py +1 -0
  10. GameSentenceMiner/owocr/owocr/__main__.py +9 -0
  11. GameSentenceMiner/owocr/owocr/config.py +148 -0
  12. GameSentenceMiner/owocr/owocr/lens_betterproto.py +1238 -0
  13. GameSentenceMiner/owocr/owocr/ocr.py +1690 -0
  14. GameSentenceMiner/owocr/owocr/run.py +1818 -0
  15. GameSentenceMiner/owocr/owocr/screen_coordinate_picker.py +109 -0
  16. GameSentenceMiner/tools/__init__.py +0 -0
  17. GameSentenceMiner/tools/audio_offset_selector.py +215 -0
  18. GameSentenceMiner/tools/ss_selector.py +135 -0
  19. GameSentenceMiner/tools/window_transparency.py +214 -0
  20. GameSentenceMiner/util/__init__.py +0 -0
  21. GameSentenceMiner/util/communication/__init__.py +22 -0
  22. GameSentenceMiner/util/communication/send.py +7 -0
  23. GameSentenceMiner/util/communication/websocket.py +94 -0
  24. GameSentenceMiner/util/configuration.py +1199 -0
  25. GameSentenceMiner/util/db.py +408 -0
  26. GameSentenceMiner/util/downloader/Untitled_json.py +472 -0
  27. GameSentenceMiner/util/downloader/__init__.py +0 -0
  28. GameSentenceMiner/util/downloader/download_tools.py +194 -0
  29. GameSentenceMiner/util/downloader/oneocr_dl.py +250 -0
  30. GameSentenceMiner/util/electron_config.py +259 -0
  31. GameSentenceMiner/util/ffmpeg.py +571 -0
  32. GameSentenceMiner/util/get_overlay_coords.py +366 -0
  33. GameSentenceMiner/util/gsm_utils.py +323 -0
  34. GameSentenceMiner/util/model.py +206 -0
  35. GameSentenceMiner/util/notification.py +157 -0
  36. GameSentenceMiner/util/text_log.py +214 -0
  37. GameSentenceMiner/util/win10toast/__init__.py +154 -0
  38. GameSentenceMiner/util/win10toast/__main__.py +22 -0
  39. GameSentenceMiner/web/__init__.py +0 -0
  40. GameSentenceMiner/web/service.py +132 -0
  41. GameSentenceMiner/web/static/__init__.py +0 -0
  42. GameSentenceMiner/web/static/apple-touch-icon.png +0 -0
  43. GameSentenceMiner/web/static/favicon-96x96.png +0 -0
  44. GameSentenceMiner/web/static/favicon.ico +0 -0
  45. GameSentenceMiner/web/static/favicon.svg +3 -0
  46. GameSentenceMiner/web/static/site.webmanifest +21 -0
  47. GameSentenceMiner/web/static/style.css +292 -0
  48. GameSentenceMiner/web/static/web-app-manifest-192x192.png +0 -0
  49. GameSentenceMiner/web/static/web-app-manifest-512x512.png +0 -0
  50. GameSentenceMiner/web/templates/__init__.py +0 -0
  51. GameSentenceMiner/web/templates/index.html +50 -0
  52. GameSentenceMiner/web/templates/text_replacements.html +238 -0
  53. GameSentenceMiner/web/templates/utility.html +483 -0
  54. GameSentenceMiner/web/texthooking_page.py +584 -0
  55. GameSentenceMiner/wip/__init___.py +0 -0
  56. {gamesentenceminer-2.14.9.dist-info → gamesentenceminer-2.14.10.dist-info}/METADATA +1 -1
  57. gamesentenceminer-2.14.10.dist-info/RECORD +79 -0
  58. gamesentenceminer-2.14.9.dist-info/RECORD +0 -24
  59. {gamesentenceminer-2.14.9.dist-info → gamesentenceminer-2.14.10.dist-info}/WHEEL +0 -0
  60. {gamesentenceminer-2.14.9.dist-info → gamesentenceminer-2.14.10.dist-info}/entry_points.txt +0 -0
  61. {gamesentenceminer-2.14.9.dist-info → gamesentenceminer-2.14.10.dist-info}/licenses/LICENSE +0 -0
  62. {gamesentenceminer-2.14.9.dist-info → gamesentenceminer-2.14.10.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,1199 @@
1
+ import dataclasses
2
+ import json
3
+ import logging
4
+ import os
5
+ import shutil
6
+ import threading
7
+ from dataclasses import dataclass, field
8
+ from logging.handlers import RotatingFileHandler
9
+ from os.path import expanduser
10
+ from sys import platform
11
+ from typing import List, Dict
12
+ import sys
13
+ from enum import Enum
14
+
15
+ import toml
16
+ from dataclasses_json import dataclass_json
17
+
18
+ from importlib import metadata
19
+
20
+
21
+
22
+ OFF = 'OFF'
23
+ # VOSK = 'VOSK'
24
+ SILERO = 'SILERO'
25
+ WHISPER = 'WHISPER'
26
+ # GROQ = 'GROQ'
27
+
28
+ # VOSK_BASE = 'BASE'
29
+ # VOSK_SMALL = 'SMALL'
30
+
31
+ WHISPER_TINY = 'tiny'
32
+ WHISPER_BASE = 'base'
33
+ WHISPER_SMALL = 'small'
34
+ WHISPER_MEDIUM = 'medium'
35
+ WHSIPER_LARGE = 'large'
36
+ WHISPER_TURBO = 'turbo'
37
+
38
+ AI_GEMINI = 'Gemini'
39
+ AI_GROQ = 'Groq'
40
+ AI_OPENAI = 'OpenAI'
41
+
42
+ INFO = 'INFO'
43
+ DEBUG = 'DEBUG'
44
+
45
+ DEFAULT_CONFIG = 'Default'
46
+
47
+ current_game = ''
48
+
49
+ supported_formats = {
50
+ 'opus': 'libopus',
51
+ 'mp3': 'libmp3lame',
52
+ 'ogg': 'libvorbis',
53
+ 'aac': 'aac',
54
+ 'm4a': 'aac',
55
+ }
56
+
57
+
58
+ def is_linux():
59
+ return platform == 'linux'
60
+
61
+
62
+ def is_windows():
63
+ return platform == 'win32'
64
+
65
+
66
+ class Locale(Enum):
67
+ English = 'en_us'
68
+ 日本語 = 'ja_jp'
69
+ 한국어 = 'ko_kr'
70
+ 中文 = 'zh_cn'
71
+ Español = 'es_es'
72
+ Français = 'fr_fr'
73
+ Deutsch = 'de_de'
74
+ Italiano = 'it_it'
75
+ Русский = 'ru_ru'
76
+
77
+ @classmethod
78
+ def from_any(cls, value: str) -> 'Locale':
79
+ """
80
+ Lookup Locale by either enum name (e.g. 'English') or value (e.g. 'en_us').
81
+ Case-insensitive.
82
+ """
83
+ value_lower = value.lower()
84
+ for locale in cls:
85
+ if locale.name.lower() == value_lower or locale.value.lower() == value_lower:
86
+ return locale
87
+ raise KeyError(f"Locale '{value}' not found.")
88
+
89
+ def __getitem__(cls, item):
90
+ try:
91
+ return cls.from_any(item)
92
+ except KeyError:
93
+ raise
94
+
95
+
96
+ # Patch Enum's __getitem__ for this class
97
+ Locale.__getitem__ = classmethod(Locale.__getitem__)
98
+
99
+
100
+ class Language(Enum):
101
+ JAPANESE = "ja"
102
+ ENGLISH = "en"
103
+ KOREAN = "ko"
104
+ CHINESE = "zh"
105
+ SPANISH = "es"
106
+ FRENCH = "fr"
107
+ GERMAN = "de"
108
+ ITALIAN = "it"
109
+ RUSSIAN = "ru"
110
+ PORTUGUESE = "pt"
111
+ HINDI = "hi"
112
+ ARABIC = "ar"
113
+ TURKISH = "tr"
114
+ DUTCH = "nl"
115
+ SWEDISH = "sv"
116
+ FINNISH = "fi"
117
+ DANISH = "da"
118
+ NORWEGIAN = "no"
119
+
120
+
121
+ AVAILABLE_LANGUAGES = [lang.value for lang in Language]
122
+ AVAILABLE_LANGUAGES_DICT = {lang.value: lang for lang in Language}
123
+
124
+
125
+ class CommonLanguages(str, Enum):
126
+ """
127
+ An Enum of the world's most common languages, based on total speaker count.
128
+
129
+ The enum member is the common English name (e.g., ENGLISH) and its
130
+ value is the ISO 639-1 two-letter code (e.g., 'en').
131
+
132
+ Inheriting from `str` allows for direct comparison and use in functions
133
+ that expect a string, e.g., `CommonLanguages.FRENCH == 'fr'`.
134
+
135
+ This list is curated from Wikipedia's "List of languages by total number of speakers"
136
+ and contains over 200 entries to provide broad but practical coverage.
137
+ """
138
+ ENGLISH = 'en'
139
+ AFRIKAANS = 'af'
140
+ AKAN = 'ak'
141
+ ALBANIAN = 'sq'
142
+ ALGERIAN_SPOKEN_ARABIC = 'arq'
143
+ AMHARIC = 'am'
144
+ ARMENIAN = 'hy'
145
+ ASSAMESE = 'as'
146
+ BAMBARA = 'bm'
147
+ BASQUE = 'eu'
148
+ BELARUSIAN = 'be'
149
+ BENGALI = 'bn'
150
+ BHOJPURI = 'bho'
151
+ BOSNIAN = 'bs'
152
+ BODO = 'brx'
153
+ BULGARIAN = 'bg'
154
+ BURMESE = 'my'
155
+ CAPE_VERDEAN_CREOLE = 'kea'
156
+ CATALAN = 'ca'
157
+ CEBUANO = 'ceb'
158
+ CHHATTISGARHI = 'hns'
159
+ CHITTAGONIAN = 'ctg'
160
+ CROATIAN = 'hr'
161
+ CZECH = 'cs'
162
+ DANISH = 'da'
163
+ DECCAN = 'dcc'
164
+ DOGRI = 'doi'
165
+ DZONGKHA = 'dz'
166
+ DUTCH = 'nl'
167
+ EGYPTIAN_SPOKEN_ARABIC = 'arz'
168
+ ESTONIAN = 'et'
169
+ EWE = 'ee'
170
+ FAROESE = 'fo'
171
+ FIJIAN = 'fj'
172
+ FINNISH = 'fi'
173
+ FRENCH = 'fr'
174
+ GALICIAN = 'gl'
175
+ GAN_CHINESE = 'gan'
176
+ GEORGIAN = 'ka'
177
+ GERMAN = 'de'
178
+ GREEK = 'el'
179
+ GREENLANDIC = 'kl'
180
+ GUJARATI = 'gu'
181
+ HAITIAN_CREOLE = 'ht'
182
+ HAUSA = 'ha'
183
+ HAKKA_CHINESE = 'hak'
184
+ HARYANVI = 'bgc'
185
+ HEBREW = 'he'
186
+ HINDI = 'hi'
187
+ HUNGARIAN = 'hu'
188
+ ICELANDIC = 'is'
189
+ IGBO = 'ig'
190
+ INDONESIAN = 'id'
191
+ IRANIAN_PERSIAN = 'fa'
192
+ IRISH = 'ga'
193
+ ITALIAN = 'it'
194
+ JAVANESE = 'jv'
195
+ JAMAICAN_PATOIS = 'jam'
196
+ JAPANESE = 'ja'
197
+ KANNADA = 'kn'
198
+ KASHMIRI = 'ks'
199
+ KAZAKH = 'kk'
200
+ KHMER = 'km'
201
+ KONGO = 'kg'
202
+ KONKANI = 'kok'
203
+ KOREAN = 'ko'
204
+ KURDISH = 'kmr'
205
+ LAO = 'lo'
206
+ LATVIAN = 'lv'
207
+ LINGALA = 'ln'
208
+ LITHUANIAN = 'lt'
209
+ LUBA_KASAI = 'lua'
210
+ LUXEMBOURGISH = 'lb'
211
+ MACEDONIAN = 'mk'
212
+ MADURESE = 'mad'
213
+ MAGAHI = 'mag'
214
+ MAITHILI = 'mai'
215
+ MALAGASY = 'mg'
216
+ MALAYALAM = 'ml'
217
+ MALTESE = 'mt'
218
+ MANDARIN_CHINESE = 'zh'
219
+ MANIPURI = 'mni'
220
+ MARATHI = 'mr'
221
+ MAORI = 'mi'
222
+ MAURITIAN_CREOLE = 'mfe'
223
+ MIN_NAN_CHINESE = 'nan'
224
+ MINANGKABAU = 'min'
225
+ MONGOLIAN = 'mn'
226
+ MONTENEGRIN = 'cnr'
227
+ MOROCCAN_SPOKEN_ARABIC = 'ary'
228
+ NDEBELE = 'nr'
229
+ NEPALI = 'ne'
230
+ NIGERIAN_PIDGIN = 'pcm'
231
+ NORTHERN_KURDISH = 'kmr'
232
+ NORTHERN_PASHTO = 'pbu'
233
+ NORTHERN_UZBEK = 'uz'
234
+ NORWEGIAN = 'no'
235
+ ODIA = 'or'
236
+ PAPIAMENTO = 'pap'
237
+ POLISH = 'pl'
238
+ PORTUGUESE = 'pt'
239
+ ROMANIAN = 'ro'
240
+ RWANDA = 'rw'
241
+ RUSSIAN = 'ru'
242
+ SAMOAN = 'sm'
243
+ SANTALI = 'sat'
244
+ SARAIKI = 'skr'
245
+ SCOTTISH_GAELIC = 'gd'
246
+ SEYCHELLOIS_CREOLE = 'crs'
247
+ SERBIAN = 'sr'
248
+ SHONA = 'sn'
249
+ SINDHI = 'sd'
250
+ SINHALA = 'si'
251
+ SLOVAK = 'sk'
252
+ SLOVENIAN = 'sl'
253
+ SOMALI = 'so'
254
+ SOTHO = 'st'
255
+ SOUTH_AZERBAIJANI = 'azb'
256
+ SOUTHERN_PASHTO = 'ps'
257
+ SPANISH = 'es'
258
+ STANDARD_ARABIC = 'ar'
259
+ SUDANESE_SPOKEN_ARABIC = 'apd'
260
+ SUNDANESE = 'su'
261
+ SWAHILI = 'sw'
262
+ SWATI = 'ss'
263
+ SWEDISH = 'sv'
264
+ SYLHETI = 'syl'
265
+ TAGALOG = 'tl'
266
+ TAMIL = 'ta'
267
+ TELUGU = 'te'
268
+ THAI = 'th'
269
+ TIGRINYA = 'ti'
270
+ TIBETAN = 'bo'
271
+ TONGAN = 'to'
272
+ TSONGA = 'ts'
273
+ TSWANA = 'tn'
274
+ TWI = 'twi'
275
+ UKRAINIAN = 'uk'
276
+ URDU = 'ur'
277
+ UYGHUR = 'ug'
278
+ VENDA = 've'
279
+ VIETNAMESE = 'vi'
280
+ WELSH = 'cy'
281
+ WESTERN_PUNJABI = 'pnb'
282
+ WOLOF = 'wo'
283
+ WU_CHINESE = 'wuu'
284
+ XHOSA = 'xh'
285
+ YORUBA = 'yo'
286
+ YUE_CHINESE = 'yue'
287
+ ZULU = 'zu'
288
+
289
+ # Helper methods
290
+
291
+ @classmethod
292
+ def get_all_codes(cls) -> list[str]:
293
+ """Returns a list of all language codes (e.g., ['en', 'zh', 'hi'])."""
294
+ return [lang.value for lang in cls]
295
+
296
+ @classmethod
297
+ def get_all_names(cls) -> list[str]:
298
+ """Returns a list of all language names (e.g., ['ENGLISH', 'MANDARIN_CHINESE'])."""
299
+ return [lang.name for lang in cls]
300
+
301
+ @classmethod
302
+ def get_all_names_pretty(cls) -> list[str]:
303
+ """Returns a list of all language names formatted for display (e.g., ['English', 'Mandarin Chinese'])."""
304
+ return [lang.name.replace('_', ' ').title() for lang in cls]
305
+
306
+ @classmethod
307
+ def get_choices(cls) -> list[tuple[str, str]]:
308
+ """
309
+ Returns a list of (value, label) tuples for use in web framework
310
+ choice fields (e.g., Django, Flask).
311
+
312
+ Example: [('en', 'English'), ('zh', 'Mandarin Chinese')]
313
+ """
314
+ return [(lang.value, lang.name.replace('_', ' ').title()) for lang in cls]
315
+
316
+ # Method to lookup language by it's name
317
+ @classmethod
318
+ def from_name(cls, name: str) -> 'CommonLanguages':
319
+ """
320
+ Looks up a language by its name (e.g., 'ENGLISH') and returns the corresponding enum member.
321
+ Raises ValueError if not found.
322
+ """
323
+ try:
324
+ return cls[name.upper()]
325
+ except KeyError:
326
+ raise ValueError(f"Language '{name}' not found in CommonLanguages")
327
+
328
+ # Method to lookup language by its code
329
+ @classmethod
330
+ def from_code(cls, code: str) -> 'CommonLanguages':
331
+ """
332
+ Looks up a language by its code (e.g., 'en') and returns the corresponding enum member.
333
+ Raises ValueError if not found.
334
+ """
335
+ for lang in cls:
336
+ if lang.value == code:
337
+ return lang
338
+ raise ValueError(
339
+ f"Language code '{code}' not found in CommonLanguages")
340
+
341
+ @classmethod
342
+ def name_from_code(cls, code: str) -> str:
343
+ """
344
+ Returns the name of the language given its code (e.g., 'en' -> 'ENGLISH').
345
+ Raises ValueError if not found.
346
+ """
347
+ return cls.from_code(code).name
348
+
349
+
350
+ PACKAGE_NAME = "GameSentenceMiner"
351
+
352
+
353
+ def get_current_version():
354
+ try:
355
+ version = metadata.version(PACKAGE_NAME)
356
+ return version
357
+ except metadata.PackageNotFoundError:
358
+ return None
359
+
360
+
361
+ def get_latest_version():
362
+ try:
363
+ import requests
364
+ response = requests.get(f"https://pypi.org/pypi/{PACKAGE_NAME}/json")
365
+ latest_version = response.json()["info"]["version"]
366
+ return latest_version
367
+ except Exception as e:
368
+ logger.error(f"Error fetching latest version: {e}")
369
+ return None
370
+
371
+
372
+ def check_for_updates(force=False):
373
+ try:
374
+ installed_version = get_current_version()
375
+ latest_version = get_latest_version()
376
+
377
+ if installed_version != latest_version or force:
378
+ logger.info(
379
+ f"Update available: {installed_version} -> {latest_version}")
380
+ return True, latest_version
381
+ else:
382
+ logger.info("You are already using the latest version.")
383
+ return False, latest_version
384
+ except Exception as e:
385
+ logger.error(f"Error checking for updates: {e}")
386
+
387
+
388
+ @dataclass_json
389
+ @dataclass
390
+ class General:
391
+ use_websocket: bool = True
392
+ use_clipboard: bool = True
393
+ use_both_clipboard_and_websocket: bool = False
394
+ merge_matching_sequential_text: bool = False
395
+ websocket_uri: str = 'localhost:6677,localhost:9001,localhost:2333'
396
+ open_config_on_startup: bool = False
397
+ open_multimine_on_startup: bool = True
398
+ texthook_replacement_regex: str = ""
399
+ texthooker_port: int = 55000
400
+ native_language: str = CommonLanguages.ENGLISH.value
401
+
402
+ def get_native_language_name(self) -> str:
403
+ try:
404
+ return CommonLanguages.name_from_code(self.native_language)
405
+ except ValueError:
406
+ return "Unknown"
407
+
408
+
409
+ @dataclass_json
410
+ @dataclass
411
+ class Paths:
412
+ folder_to_watch: str = expanduser("~/Videos/GSM")
413
+ output_folder: str = expanduser("~/Videos/GSM/Output")
414
+ copy_temp_files_to_output_folder: bool = False
415
+ open_output_folder_on_card_creation: bool = False
416
+ copy_trimmed_replay_to_output_folder: bool = False
417
+ remove_video: bool = True
418
+ remove_audio: bool = False
419
+ remove_screenshot: bool = False
420
+
421
+ def __post_init__(self):
422
+ if self.folder_to_watch:
423
+ self.folder_to_watch = os.path.normpath(self.folder_to_watch)
424
+ if self.output_folder:
425
+ self.output_folder = os.path.normpath(self.output_folder)
426
+
427
+
428
+ @dataclass_json
429
+ @dataclass
430
+ class Anki:
431
+ update_anki: bool = True
432
+ url: str = 'http://127.0.0.1:8765'
433
+ sentence_field: str = "Sentence"
434
+ sentence_audio_field: str = "SentenceAudio"
435
+ picture_field: str = "Picture"
436
+ word_field: str = 'Expression'
437
+ previous_sentence_field: str = ''
438
+ previous_image_field: str = ''
439
+ # Initialize to None and set it in __post_init__
440
+ custom_tags: List[str] = None
441
+ tags_to_check: List[str] = None
442
+ add_game_tag: bool = True
443
+ polling_rate: int = 200
444
+ overwrite_audio: bool = False
445
+ overwrite_picture: bool = True
446
+ multi_overwrites_sentence: bool = True
447
+ parent_tag: str = "Game"
448
+
449
+ def __post_init__(self):
450
+ if self.custom_tags is None:
451
+ self.custom_tags = ['GSM']
452
+ if self.tags_to_check is None:
453
+ self.tags_to_check = []
454
+
455
+
456
+ @dataclass_json
457
+ @dataclass
458
+ class Features:
459
+ full_auto: bool = True
460
+ notify_on_update: bool = True
461
+ open_anki_edit: bool = False
462
+ open_anki_in_browser: bool = True
463
+ browser_query: str = ''
464
+ backfill_audio: bool = False
465
+
466
+
467
+ @dataclass_json
468
+ @dataclass
469
+ class Screenshot:
470
+ enabled: bool = True
471
+ width: str = 0
472
+ height: str = 0
473
+ quality: str = 85
474
+ extension: str = "webp"
475
+ custom_ffmpeg_settings: str = ''
476
+ custom_ffmpeg_option_selected: str = ''
477
+ screenshot_hotkey_updates_anki: bool = False
478
+ seconds_after_line: float = 1.0
479
+ use_beginning_of_line_as_screenshot: bool = True
480
+ use_new_screenshot_logic: bool = False
481
+ screenshot_timing_setting: str = 'beginning' # 'middle', 'end'
482
+ use_screenshot_selector: bool = False
483
+
484
+ def __post_init__(self):
485
+ if not self.screenshot_timing_setting and self.use_beginning_of_line_as_screenshot:
486
+ self.screenshot_timing_setting = 'beginning'
487
+ if not self.screenshot_timing_setting and self.use_new_screenshot_logic:
488
+ self.screenshot_timing_setting = 'middle'
489
+ if not self.screenshot_timing_setting and not self.use_beginning_of_line_as_screenshot and not self.use_new_screenshot_logic:
490
+ self.screenshot_timing_setting = 'end'
491
+ if self.width and self.height == 0:
492
+ self.height = -1
493
+ if self.width == 0 and self.height:
494
+ self.width = -1
495
+
496
+
497
+ @dataclass_json
498
+ @dataclass
499
+ class Audio:
500
+ enabled: bool = True
501
+ extension: str = 'opus'
502
+ beginning_offset: float = -0.5
503
+ end_offset: float = 0.5
504
+ pre_vad_end_offset: float = 0.0
505
+ ffmpeg_reencode_options: str = '-c:a libopus -f opus -af \"afade=t=in:d=0.10\"' if is_windows() else ''
506
+ ffmpeg_reencode_options_to_use: str = ''
507
+ external_tool: str = ""
508
+ anki_media_collection: str = ""
509
+ external_tool_enabled: bool = True
510
+ custom_encode_settings: str = ''
511
+
512
+ def __post_init__(self):
513
+ self.ffmpeg_reencode_options_to_use = self.ffmpeg_reencode_options.replace(
514
+ "{format}", self.extension).replace("{encoder}", supported_formats.get(self.extension, ''))
515
+ if not self.anki_media_collection:
516
+ self.anki_media_collection = get_default_anki_media_collection_path()
517
+ if self.anki_media_collection:
518
+ self.anki_media_collection = os.path.normpath(
519
+ self.anki_media_collection)
520
+ if self.external_tool:
521
+ self.external_tool = os.path.normpath(self.external_tool)
522
+
523
+
524
+ @dataclass_json
525
+ @dataclass
526
+ class OBS:
527
+ open_obs: bool = True
528
+ close_obs: bool = True
529
+ host: str = "127.0.0.1"
530
+ port: int = 7274
531
+ password: str = "your_password"
532
+ get_game_from_scene: bool = True
533
+ minimum_replay_size: int = 0
534
+ turn_off_output_check: bool = False
535
+
536
+
537
+ @dataclass_json
538
+ @dataclass
539
+ class Hotkeys:
540
+ reset_line: str = 'f5'
541
+ take_screenshot: str = 'f6'
542
+ open_utility: str = 'ctrl+m'
543
+ play_latest_audio: str = 'f7'
544
+
545
+
546
+ @dataclass_json
547
+ @dataclass
548
+ class VAD:
549
+ whisper_model: str = WHISPER_BASE
550
+ do_vad_postprocessing: bool = True
551
+ language: str = 'ja'
552
+ # vosk_url: str = VOSK_BASE
553
+ selected_vad_model: str = WHISPER
554
+ backup_vad_model: str = SILERO
555
+ trim_beginning: bool = False
556
+ beginning_offset: float = -0.25
557
+ add_audio_on_no_results: bool = False
558
+ cut_and_splice_segments: bool = False
559
+ splice_padding: float = 0.1
560
+
561
+ def is_silero(self):
562
+ return self.selected_vad_model == SILERO or self.backup_vad_model == SILERO
563
+
564
+ def is_whisper(self):
565
+ return self.selected_vad_model == WHISPER or self.backup_vad_model == WHISPER
566
+
567
+ # def is_vosk(self):
568
+ # return self.selected_vad_model == VOSK or self.backup_vad_model == VOSK
569
+
570
+ # def is_groq(self):
571
+ # return self.selected_vad_model == GROQ or self.backup_vad_model == GROQ
572
+
573
+
574
+ @dataclass_json
575
+ @dataclass
576
+ class Advanced:
577
+ plaintext_websocket_port: int = -1
578
+ audio_player_path: str = ''
579
+ video_player_path: str = ''
580
+ show_screenshot_buttons: bool = False
581
+ multi_line_line_break: str = '<br>'
582
+ multi_line_sentence_storage_field: str = ''
583
+ ocr_websocket_port: int = 9002
584
+ texthooker_communication_websocket_port: int = 55001
585
+
586
+ def __post_init__(self):
587
+ if self.plaintext_websocket_port == -1:
588
+ self.plaintext_websocket_port = self.texthooker_communication_websocket_port + 1
589
+
590
+
591
+ @dataclass_json
592
+ @dataclass
593
+ class Ai:
594
+ enabled: bool = False
595
+ anki_field: str = ''
596
+ provider: str = AI_GEMINI
597
+ gemini_model: str = 'gemini-2.5-flash-lite'
598
+ groq_model: str = 'meta-llama/llama-4-scout-17b-16e-instruct'
599
+ gemini_api_key: str = ''
600
+ api_key: str = '' # Legacy support, will be moved to gemini_api_key if provider is gemini
601
+ groq_api_key: str = ''
602
+ open_ai_url: str = ''
603
+ open_ai_model: str = ''
604
+ open_ai_api_key: str = ''
605
+ use_canned_translation_prompt: bool = True
606
+ use_canned_context_prompt: bool = False
607
+ custom_prompt: str = ''
608
+ dialogue_context_length: int = 10
609
+
610
+ def __post_init__(self):
611
+ if not self.gemini_api_key:
612
+ self.gemini_api_key = self.api_key
613
+ if self.provider == 'gemini':
614
+ self.provider = AI_GEMINI
615
+ if self.provider == 'groq':
616
+ self.provider = AI_GROQ
617
+ if self.gemini_model in ['RECOMMENDED', 'OTHER']:
618
+ self.gemini_model = 'gemini-2.5-flash-lite'
619
+ if self.groq_model in ['RECOMMENDED', 'OTHER']:
620
+ self.groq_model = 'meta-llama/llama-4-scout-17b-16e-instruct'
621
+
622
+ # Change Legacy Model Name
623
+ if self.gemini_model == 'gemini-2.5-flash-lite-preview-06-17':
624
+ self.gemini_model = 'gemini-2.5-flash-lite'
625
+
626
+
627
+ @dataclass_json
628
+ @dataclass
629
+ class Overlay:
630
+ websocket_port: int = 55499
631
+ monitor_to_capture: int = 0
632
+
633
+ def __post_init__(self):
634
+ if self.monitor_to_capture == -1:
635
+ self.monitor_to_capture = 0 # Default to the first monitor if not set
636
+
637
+
638
+ @dataclass_json
639
+ @dataclass
640
+ class WIP:
641
+ pass
642
+
643
+
644
+ @dataclass_json
645
+ @dataclass
646
+ class ProfileConfig:
647
+ name: str = 'Default'
648
+ scenes: List[str] = field(default_factory=list)
649
+ general: General = field(default_factory=General)
650
+ paths: Paths = field(default_factory=Paths)
651
+ anki: Anki = field(default_factory=Anki)
652
+ features: Features = field(default_factory=Features)
653
+ screenshot: Screenshot = field(default_factory=Screenshot)
654
+ audio: Audio = field(default_factory=Audio)
655
+ obs: OBS = field(default_factory=OBS)
656
+ hotkeys: Hotkeys = field(default_factory=Hotkeys)
657
+ vad: VAD = field(default_factory=VAD)
658
+ advanced: Advanced = field(default_factory=Advanced)
659
+ ai: Ai = field(default_factory=Ai)
660
+ overlay: Overlay = field(default_factory=Overlay)
661
+ wip: WIP = field(default_factory=WIP)
662
+
663
+ def get_field_value(self, section: str, field_name: str):
664
+ section_obj = getattr(self, section, None)
665
+ if section_obj and hasattr(section_obj, field_name):
666
+ return getattr(section_obj, field_name)
667
+ else:
668
+ raise ValueError(
669
+ f"Field '{field_name}' not found in section '{section}' of ProfileConfig.")
670
+
671
+ # This is just for legacy support
672
+ def load_from_toml(self, file_path: str):
673
+ with open(file_path, 'r') as f:
674
+ config_data = toml.load(f)
675
+
676
+ self.paths.folder_to_watch = expanduser(config_data['paths'].get(
677
+ 'folder_to_watch', self.paths.folder_to_watch))
678
+
679
+ self.anki.url = config_data['anki'].get('url', self.anki.url)
680
+ self.anki.sentence_field = config_data['anki'].get(
681
+ 'sentence_field', self.anki.sentence_field)
682
+ self.anki.sentence_audio_field = config_data['anki'].get(
683
+ 'sentence_audio_field', self.anki.sentence_audio_field)
684
+ self.anki.word_field = config_data['anki'].get(
685
+ 'word_field', self.anki.word_field)
686
+ self.anki.picture_field = config_data['anki'].get(
687
+ 'picture_field', self.anki.picture_field)
688
+ self.anki.custom_tags = config_data['anki'].get(
689
+ 'custom_tags', self.anki.custom_tags)
690
+ self.anki.add_game_tag = config_data['anki'].get(
691
+ 'add_game_tag', self.anki.add_game_tag)
692
+ self.anki.polling_rate = config_data['anki'].get(
693
+ 'polling_rate', self.anki.polling_rate)
694
+ self.anki.overwrite_audio = config_data['anki_overwrites'].get(
695
+ 'overwrite_audio', self.anki.overwrite_audio)
696
+ self.anki.overwrite_picture = config_data['anki_overwrites'].get('overwrite_picture',
697
+ self.anki.overwrite_picture)
698
+
699
+ self.features.full_auto = config_data['features'].get(
700
+ 'do_vosk_postprocessing', self.features.full_auto)
701
+ self.features.notify_on_update = config_data['features'].get(
702
+ 'notify_on_update', self.features.notify_on_update)
703
+ self.features.open_anki_edit = config_data['features'].get(
704
+ 'open_anki_edit', self.features.open_anki_edit)
705
+ self.features.backfill_audio = config_data['features'].get(
706
+ 'backfill_audio', self.features.backfill_audio)
707
+
708
+ self.screenshot.width = config_data['screenshot'].get(
709
+ 'width', self.screenshot.width)
710
+ self.screenshot.height = config_data['screenshot'].get(
711
+ 'height', self.screenshot.height)
712
+ self.screenshot.quality = config_data['screenshot'].get(
713
+ 'quality', self.screenshot.quality)
714
+ self.screenshot.extension = config_data['screenshot'].get(
715
+ 'extension', self.screenshot.extension)
716
+ self.screenshot.custom_ffmpeg_settings = config_data['screenshot'].get('custom_ffmpeg_settings',
717
+ self.screenshot.custom_ffmpeg_settings)
718
+
719
+ self.audio.extension = config_data['audio'].get(
720
+ 'extension', self.audio.extension)
721
+ self.audio.beginning_offset = config_data['audio'].get(
722
+ 'beginning_offset', self.audio.beginning_offset)
723
+ self.audio.end_offset = config_data['audio'].get(
724
+ 'end_offset', self.audio.end_offset)
725
+ self.audio.ffmpeg_reencode_options = config_data['audio'].get('ffmpeg_reencode_options',
726
+ self.audio.ffmpeg_reencode_options)
727
+
728
+ self.vad.whisper_model = config_data['vosk'].get(
729
+ 'whisper_model', self.vad.whisper_model)
730
+ self.vad.vosk_url = config_data['vosk'].get('url', self.vad.vosk_url)
731
+ self.vad.do_vad_postprocessing = config_data['features'].get('do_vosk_postprocessing',
732
+ self.vad.do_vad_postprocessing)
733
+ self.vad.trim_beginning = config_data['audio'].get(
734
+ 'vosk_trim_beginning', self.vad.trim_beginning)
735
+
736
+ self.obs.host = config_data['obs'].get('host', self.obs.host)
737
+ self.obs.port = config_data['obs'].get('port', self.obs.port)
738
+ self.obs.password = config_data['obs'].get(
739
+ 'password', self.obs.password)
740
+
741
+ self.general.use_websocket = config_data['websocket'].get(
742
+ 'enabled', self.general.use_websocket)
743
+ self.general.websocket_uri = config_data['websocket'].get(
744
+ 'uri', self.general.websocket_uri)
745
+
746
+ self.hotkeys.reset_line = config_data['hotkeys'].get(
747
+ 'reset_line', self.hotkeys.reset_line)
748
+ self.hotkeys.take_screenshot = config_data['hotkeys'].get(
749
+ 'take_screenshot', self.hotkeys.take_screenshot)
750
+
751
+ with open(get_config_path(), 'w') as f:
752
+ f.write(self.to_json(indent=4))
753
+ print(
754
+ 'config.json successfully generated from previous settings. config.toml will no longer be used.')
755
+
756
+ return self
757
+
758
+ def restart_required(self, previous):
759
+ previous: ProfileConfig
760
+ if any([previous.paths.folder_to_watch != self.paths.folder_to_watch,
761
+ previous.obs.open_obs != self.obs.open_obs,
762
+ previous.obs.host != self.obs.host,
763
+ previous.obs.port != self.obs.port
764
+ ]):
765
+ logger.info("Restart Required for Some Settings that were Changed")
766
+ return True
767
+ return False
768
+
769
+ def config_changed(self, new: 'ProfileConfig') -> bool:
770
+ return self != new
771
+
772
+
773
+ @dataclass_json
774
+ @dataclass
775
+ class Config:
776
+ configs: Dict[str, ProfileConfig] = field(default_factory=dict)
777
+ current_profile: str = DEFAULT_CONFIG
778
+ switch_to_default_if_not_found: bool = True
779
+ locale: str = Locale.English.value
780
+
781
+ @classmethod
782
+ def new(cls):
783
+ instance = cls(
784
+ configs={DEFAULT_CONFIG: ProfileConfig()}, current_profile=DEFAULT_CONFIG)
785
+ return instance
786
+
787
+ def get_locale(self) -> Locale:
788
+ try:
789
+ return Locale.from_any(self.locale)
790
+ except KeyError:
791
+ logger.warning(
792
+ f"Locale '{self.locale}' not found. Defaulting to English.")
793
+ return Locale.English
794
+
795
+ @classmethod
796
+ def load(cls):
797
+ config_path = get_config_path()
798
+ if os.path.exists(config_path):
799
+ with open(config_path, 'r') as file:
800
+ data = json.load(file)
801
+ return cls.from_dict(data)
802
+ else:
803
+ return cls.new()
804
+
805
+ def save(self):
806
+ with open(get_config_path(), 'w') as file:
807
+ json.dump(self.to_dict(), file, indent=4)
808
+ return self
809
+
810
+ def get_config(self) -> ProfileConfig:
811
+ if self.current_profile not in self.configs:
812
+ logger.warning(
813
+ f"Profile '{self.current_profile}' not found. Switching to default profile.")
814
+ self.current_profile = DEFAULT_CONFIG
815
+ return self.configs[self.current_profile]
816
+
817
+ def set_config_for_profile(self, profile: str, config: ProfileConfig):
818
+ config.name = profile
819
+ self.configs[profile] = config
820
+
821
+ def has_config_for_current_game(self):
822
+ return current_game in self.configs
823
+
824
+ def get_all_profile_names(self):
825
+ return list(self.configs.keys())
826
+
827
+ def get_default_config(self):
828
+ return self.configs[DEFAULT_CONFIG]
829
+
830
+ def sync_changed_fields(self, previous_config: ProfileConfig):
831
+ current_config = self.get_config()
832
+
833
+ for section in current_config.to_dict():
834
+ if dataclasses.is_dataclass(getattr(current_config, section, None)):
835
+ for field_name in getattr(current_config, section, None).to_dict():
836
+ config_section = getattr(current_config, section, None)
837
+ previous_config_section = getattr(
838
+ previous_config, section, None)
839
+ current_value = getattr(config_section, field_name, None)
840
+ previous_value = getattr(
841
+ previous_config_section, field_name, None)
842
+ if str(current_value).strip() != str(previous_value).strip():
843
+ logger.info(
844
+ f"Syncing changed field '{field_name}' from '{previous_value}' to '{current_value}'")
845
+ for profile in self.configs.values():
846
+ if profile != current_config:
847
+ profile_section = getattr(
848
+ profile, section, None)
849
+ if profile_section:
850
+ setattr(profile_section,
851
+ field_name, current_value)
852
+ logger.info(
853
+ f"Updated '{field_name}' in profile '{profile.name}'")
854
+
855
+ return self
856
+
857
+ def sync_shared_fields(self):
858
+ config = self.get_config()
859
+ for profile in self.configs.values():
860
+ self.sync_shared_field(
861
+ config.hotkeys, profile.hotkeys, "reset_line")
862
+ self.sync_shared_field(
863
+ config.hotkeys, profile.hotkeys, "take_screenshot")
864
+ self.sync_shared_field(
865
+ config.hotkeys, profile.hotkeys, "open_utility")
866
+ self.sync_shared_field(
867
+ config.hotkeys, profile.hotkeys, "play_latest_audio")
868
+ self.sync_shared_field(config.anki, profile.anki, "url")
869
+ self.sync_shared_field(config.anki, profile.anki, "sentence_field")
870
+ self.sync_shared_field(
871
+ config.anki, profile.anki, "sentence_audio_field")
872
+ self.sync_shared_field(config.anki, profile.anki, "picture_field")
873
+ self.sync_shared_field(config.anki, profile.anki, "word_field")
874
+ self.sync_shared_field(
875
+ config.anki, profile.anki, "previous_sentence_field")
876
+ self.sync_shared_field(
877
+ config.anki, profile.anki, "previous_image_field")
878
+ self.sync_shared_field(config.anki, profile.anki, "tags_to_check")
879
+ self.sync_shared_field(config.anki, profile.anki, "add_game_tag")
880
+ self.sync_shared_field(config.anki, profile.anki, "polling_rate")
881
+ self.sync_shared_field(
882
+ config.anki, profile.anki, "overwrite_audio")
883
+ self.sync_shared_field(
884
+ config.anki, profile.anki, "overwrite_picture")
885
+ self.sync_shared_field(
886
+ config.anki, profile.anki, "multi_overwrites_sentence")
887
+ self.sync_shared_field(
888
+ config.general, profile.general, "open_config_on_startup")
889
+ self.sync_shared_field(
890
+ config.general, profile.general, "open_multimine_on_startup")
891
+ self.sync_shared_field(
892
+ config.general, profile.general, "websocket_uri")
893
+ self.sync_shared_field(
894
+ config.general, profile.general, "texthooker_port")
895
+ self.sync_shared_field(
896
+ config.audio, profile.audio, "external_tool")
897
+ self.sync_shared_field(
898
+ config.audio, profile.audio, "anki_media_collection")
899
+ self.sync_shared_field(
900
+ config.audio, profile.audio, "external_tool_enabled")
901
+ self.sync_shared_field(
902
+ config.audio, profile.audio, "custom_encode_settings")
903
+ self.sync_shared_field(
904
+ config.screenshot, profile.screenshot, "custom_ffmpeg_settings")
905
+ self.sync_shared_field(config, profile, "advanced")
906
+ self.sync_shared_field(config, profile, "paths")
907
+ self.sync_shared_field(config, profile, "obs")
908
+ self.sync_shared_field(config, profile, "wip")
909
+ self.sync_shared_field(config.ai, profile.ai, "anki_field")
910
+ self.sync_shared_field(config.ai, profile.ai, "provider")
911
+ self.sync_shared_field(config.ai, profile.ai, "api_key")
912
+ self.sync_shared_field(config.ai, profile.ai, "gemini_api_key")
913
+ self.sync_shared_field(config.ai, profile.ai, "groq_api_key")
914
+
915
+ return self
916
+
917
+ def sync_shared_field(self, config, config2, field_name):
918
+ try:
919
+ config_value = getattr(config, field_name, None)
920
+ config2_value = getattr(config2, field_name, None)
921
+
922
+ if config_value != config2_value: # Check if values are different.
923
+ if config_value is not None:
924
+ logging.info(
925
+ f"Syncing shared field '{field_name}' to other profile.")
926
+ setattr(config2, field_name, config_value)
927
+ elif config2_value is not None:
928
+ logging.info(
929
+ f"Syncing shared field '{field_name}' to current profile.")
930
+ setattr(config, field_name, config2_value)
931
+ except AttributeError as e:
932
+ logging.error(f"AttributeError during sync of '{field_name}': {e}")
933
+ except Exception as e:
934
+ logging.error(
935
+ f"An unexpected error occurred during sync of '{field_name}': {e}")
936
+
937
+
938
+ def get_default_anki_path():
939
+ if platform == 'win32': # Windows
940
+ base_dir = os.getenv('APPDATA')
941
+ else: # macOS and Linux
942
+ base_dir = '~/.local/share/'
943
+ config_dir = os.path.join(base_dir, 'Anki2')
944
+ return config_dir
945
+
946
+
947
+ def get_default_anki_media_collection_path():
948
+ return os.path.join(get_default_anki_path(), 'User 1', 'collection.media')
949
+
950
+
951
+ def get_app_directory():
952
+ if platform == 'win32': # Windows
953
+ appdata_dir = os.getenv('APPDATA')
954
+ else: # macOS and Linux
955
+ appdata_dir = os.path.expanduser('~/.config')
956
+ config_dir = os.path.join(appdata_dir, 'GameSentenceMiner')
957
+ # Create the directory if it doesn't exist
958
+ os.makedirs(config_dir, exist_ok=True)
959
+ return config_dir
960
+
961
+
962
+ def get_log_path():
963
+ path = os.path.join(get_app_directory(), "logs", 'gamesentenceminer.log')
964
+ os.makedirs(os.path.dirname(path), exist_ok=True)
965
+ return path
966
+
967
+
968
+ temp_directory = ''
969
+
970
+
971
+ def get_temporary_directory(delete=False):
972
+ global temp_directory
973
+ if not temp_directory:
974
+ temp_directory = os.path.join(get_app_directory(), 'temp')
975
+ os.makedirs(temp_directory, exist_ok=True)
976
+ if delete:
977
+ for filename in os.listdir(temp_directory):
978
+ file_path = os.path.join(temp_directory, filename)
979
+ try:
980
+ if os.path.isfile(file_path) or os.path.islink(file_path):
981
+ os.unlink(file_path)
982
+ elif os.path.isdir(file_path):
983
+ shutil.rmtree(file_path)
984
+ except Exception as e:
985
+ logger.error(f"Failed to delete {file_path}. Reason: {e}")
986
+ return temp_directory
987
+
988
+
989
+ def get_config_path():
990
+ return os.path.join(get_app_directory(), 'config.json')
991
+
992
+
993
+ def load_config():
994
+ config_path = get_config_path()
995
+
996
+ if os.path.exists('config.json') and not os.path.exists(config_path):
997
+ shutil.copy('config.json', config_path)
998
+
999
+ if os.path.exists(config_path):
1000
+ try:
1001
+ with open(config_path, 'r') as file:
1002
+ config_file = json.load(file)
1003
+ if "current_profile" in config_file:
1004
+ return Config.from_dict(config_file)
1005
+ else:
1006
+ print(f"Loading Profile-less Config, Converting to new Config!")
1007
+ with open(config_path, 'r') as file:
1008
+ config_file = json.load(file)
1009
+
1010
+ config = ProfileConfig.from_dict(config_file)
1011
+ new_config = Config(
1012
+ configs={DEFAULT_CONFIG: config}, current_profile=DEFAULT_CONFIG)
1013
+
1014
+ config.save()
1015
+ return new_config
1016
+ except json.JSONDecodeError as e:
1017
+ logger.error(
1018
+ f"Error parsing config.json, saving backup and returning new config: {e}")
1019
+ shutil.copy(config_path, config_path + '.bak')
1020
+ config = Config.new()
1021
+ config.save()
1022
+ return config
1023
+ elif os.path.exists('config.toml'):
1024
+ config = ProfileConfig().load_from_toml('config.toml')
1025
+ new_config = Config({DEFAULT_CONFIG: config},
1026
+ current_profile=DEFAULT_CONFIG)
1027
+ return new_config
1028
+ else:
1029
+ config = Config.new()
1030
+ config.save()
1031
+ return config
1032
+
1033
+
1034
+ config_instance: Config = None
1035
+
1036
+
1037
+ def get_config():
1038
+ global config_instance
1039
+ if config_instance is None:
1040
+ config_instance = load_config()
1041
+ config = config_instance.get_config()
1042
+
1043
+ if config.features.backfill_audio and config.features.full_auto:
1044
+ logger.warning(
1045
+ "Backfill audio is enabled, but full auto is also enabled. Disabling backfill...")
1046
+ config.features.backfill_audio = False
1047
+
1048
+ # print(config_instance.get_config())
1049
+ return config_instance.get_config()
1050
+
1051
+
1052
+ def reload_config():
1053
+ global config_instance
1054
+ config_instance = load_config()
1055
+ config = config_instance.get_config()
1056
+
1057
+ if config.features.backfill_audio and config.features.full_auto:
1058
+ logger.warning(
1059
+ "Backfill is enabled, but full auto is also enabled. Disabling backfill...")
1060
+ config.features.backfill_audio = False
1061
+
1062
+
1063
+ def get_master_config():
1064
+ return config_instance
1065
+
1066
+
1067
+ def save_full_config(config):
1068
+ with open(get_config_path(), 'w') as file:
1069
+ json.dump(config.to_dict(), file, indent=4)
1070
+
1071
+
1072
+ def save_current_config(config):
1073
+ global config_instance
1074
+ config_instance.set_config_for_profile(
1075
+ config_instance.current_profile, config)
1076
+ save_full_config(config_instance)
1077
+
1078
+
1079
+ def switch_profile_and_save(profile_name):
1080
+ global config_instance
1081
+ config_instance.current_profile = profile_name
1082
+ save_full_config(config_instance)
1083
+ return config_instance.get_config()
1084
+
1085
+
1086
+ sys.stdout.reconfigure(encoding='utf-8')
1087
+ sys.stderr.reconfigure(encoding='utf-8')
1088
+
1089
+ logger = logging.getLogger("GameSentenceMiner")
1090
+ # Set the base level to DEBUG so that all messages are captured
1091
+ logger.setLevel(logging.DEBUG)
1092
+ formatter = logging.Formatter(
1093
+ '%(asctime)s - %(name)s - %(levelname)s - %(message)s')
1094
+
1095
+ # Create console handler with level INFO
1096
+ console_handler = logging.StreamHandler(sys.stdout)
1097
+ console_handler.setLevel(logging.INFO)
1098
+
1099
+ console_handler.setFormatter(formatter)
1100
+
1101
+ logger.addHandler(console_handler)
1102
+
1103
+ file_path = get_log_path()
1104
+ try:
1105
+ if os.path.exists(file_path) and os.path.getsize(file_path) > 1 * 1024 * 1024 and os.access(file_path, os.W_OK):
1106
+ old_log_path = os.path.join(os.path.dirname(
1107
+ file_path), "gamesentenceminer_old.log")
1108
+ if os.path.exists(old_log_path):
1109
+ os.remove(old_log_path)
1110
+ shutil.move(file_path, old_log_path)
1111
+ except Exception as e:
1112
+ logger.info(
1113
+ "Couldn't rotate log, probably because the file is being written to by another process. NOT AN ERROR")
1114
+
1115
+ file_handler = logging.FileHandler(file_path, encoding='utf-8')
1116
+ file_handler.setLevel(logging.DEBUG)
1117
+ file_handler.setFormatter(formatter)
1118
+ logger.addHandler(file_handler)
1119
+
1120
+ DB_PATH = os.path.join(get_app_directory(), 'gsm.db')
1121
+
1122
+
1123
+ class GsmAppState:
1124
+ def __init__(self):
1125
+ self.line_for_audio = None
1126
+ self.line_for_screenshot = None
1127
+ self.anki_note_for_screenshot = None
1128
+ self.previous_line_for_audio = None
1129
+ self.previous_line_for_screenshot = None
1130
+ self.previous_trim_args = None
1131
+ self.previous_audio = None
1132
+ self.previous_screenshot = None
1133
+ self.previous_replay = None
1134
+ self.lock = threading.Lock()
1135
+ self.last_mined_line = None
1136
+ self.keep_running = True
1137
+ self.current_game = ''
1138
+ self.videos_to_remove = set()
1139
+
1140
+
1141
+ @dataclass_json
1142
+ @dataclass
1143
+ class AnkiUpdateResult:
1144
+ success: bool = False
1145
+ audio_in_anki: str = ''
1146
+ screenshot_in_anki: str = ''
1147
+ prev_screenshot_in_anki: str = ''
1148
+ sentence_in_anki: str = ''
1149
+ multi_line: bool = False
1150
+
1151
+ @staticmethod
1152
+ def failure():
1153
+ return AnkiUpdateResult(success=False, audio_in_anki='', screenshot_in_anki='', prev_screenshot_in_anki='', sentence_in_anki='', multi_line=False)
1154
+
1155
+
1156
+ @dataclass_json
1157
+ @dataclass
1158
+ class GsmStatus:
1159
+ ready: bool = False
1160
+ status: bool = "Initializing"
1161
+ cards_created: int = 0
1162
+ websockets_connected: List[str] = field(default_factory=list)
1163
+ obs_connected: bool = False
1164
+ anki_connected: bool = False
1165
+ last_line_received: str = None
1166
+ words_being_processed: List[str] = field(default_factory=list)
1167
+ clipboard_enabled: bool = True
1168
+
1169
+ def add_word_being_processed(self, word: str):
1170
+ if word not in self.words_being_processed:
1171
+ self.words_being_processed.append(word)
1172
+
1173
+ def remove_word_being_processed(self, word: str):
1174
+ if word in self.words_being_processed:
1175
+ self.words_being_processed.remove(word)
1176
+
1177
+
1178
+ def is_running_from_source():
1179
+ # Check for .git directory at the project root
1180
+ current_dir = os.path.dirname(os.path.abspath(__file__))
1181
+ project_root = current_dir
1182
+ while project_root != os.path.dirname(project_root): # Avoid infinite loop
1183
+ if os.path.isdir(os.path.join(project_root, '.git')):
1184
+ return True
1185
+ if os.path.isfile(os.path.join(project_root, 'pyproject.toml')):
1186
+ return True
1187
+ project_root = os.path.dirname(project_root)
1188
+ return False
1189
+
1190
+
1191
+ gsm_status = GsmStatus()
1192
+ anki_results = {}
1193
+ gsm_state = GsmAppState()
1194
+ is_dev = is_running_from_source()
1195
+
1196
+ is_beangate = os.path.exists("C:/Users/Beangate")
1197
+
1198
+ logger.debug(f"Running in development mode: {is_dev}")
1199
+ logger.debug(f"Running on Beangate's PC: {is_beangate}")