GameSentenceMiner 2.17.7__py3-none-any.whl → 2.18.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.
Files changed (51) hide show
  1. GameSentenceMiner/ai/ai_prompting.py +6 -6
  2. GameSentenceMiner/anki.py +236 -152
  3. GameSentenceMiner/gametext.py +7 -4
  4. GameSentenceMiner/gsm.py +49 -10
  5. GameSentenceMiner/locales/en_us.json +7 -3
  6. GameSentenceMiner/locales/ja_jp.json +8 -4
  7. GameSentenceMiner/locales/zh_cn.json +8 -4
  8. GameSentenceMiner/obs.py +238 -59
  9. GameSentenceMiner/ocr/owocr_helper.py +1 -1
  10. GameSentenceMiner/tools/ss_selector.py +7 -8
  11. GameSentenceMiner/ui/__init__.py +0 -0
  12. GameSentenceMiner/ui/anki_confirmation.py +187 -0
  13. GameSentenceMiner/{config_gui.py → ui/config_gui.py} +100 -35
  14. GameSentenceMiner/ui/screenshot_selector.py +215 -0
  15. GameSentenceMiner/util/configuration.py +124 -22
  16. GameSentenceMiner/util/db.py +22 -13
  17. GameSentenceMiner/util/downloader/download_tools.py +2 -2
  18. GameSentenceMiner/util/ffmpeg.py +24 -30
  19. GameSentenceMiner/util/get_overlay_coords.py +34 -34
  20. GameSentenceMiner/util/gsm_utils.py +31 -1
  21. GameSentenceMiner/util/text_log.py +11 -9
  22. GameSentenceMiner/vad.py +31 -12
  23. GameSentenceMiner/web/database_api.py +742 -123
  24. GameSentenceMiner/web/static/css/dashboard-shared.css +241 -0
  25. GameSentenceMiner/web/static/css/kanji-grid.css +94 -2
  26. GameSentenceMiner/web/static/css/overview.css +850 -0
  27. GameSentenceMiner/web/static/css/popups-shared.css +126 -0
  28. GameSentenceMiner/web/static/css/shared.css +97 -0
  29. GameSentenceMiner/web/static/css/stats.css +192 -597
  30. GameSentenceMiner/web/static/js/anki_stats.js +6 -4
  31. GameSentenceMiner/web/static/js/database.js +209 -5
  32. GameSentenceMiner/web/static/js/goals.js +610 -0
  33. GameSentenceMiner/web/static/js/kanji-grid.js +267 -4
  34. GameSentenceMiner/web/static/js/overview.js +1176 -0
  35. GameSentenceMiner/web/static/js/shared.js +25 -0
  36. GameSentenceMiner/web/static/js/stats.js +154 -1459
  37. GameSentenceMiner/web/stats.py +2 -2
  38. GameSentenceMiner/web/templates/anki_stats.html +5 -0
  39. GameSentenceMiner/web/templates/components/navigation.html +3 -1
  40. GameSentenceMiner/web/templates/database.html +73 -1
  41. GameSentenceMiner/web/templates/goals.html +376 -0
  42. GameSentenceMiner/web/templates/index.html +13 -11
  43. GameSentenceMiner/web/templates/overview.html +416 -0
  44. GameSentenceMiner/web/templates/stats.html +46 -251
  45. GameSentenceMiner/web/texthooking_page.py +18 -0
  46. {gamesentenceminer-2.17.7.dist-info → gamesentenceminer-2.18.0.dist-info}/METADATA +5 -1
  47. {gamesentenceminer-2.17.7.dist-info → gamesentenceminer-2.18.0.dist-info}/RECORD +51 -41
  48. {gamesentenceminer-2.17.7.dist-info → gamesentenceminer-2.18.0.dist-info}/WHEEL +0 -0
  49. {gamesentenceminer-2.17.7.dist-info → gamesentenceminer-2.18.0.dist-info}/entry_points.txt +0 -0
  50. {gamesentenceminer-2.17.7.dist-info → gamesentenceminer-2.18.0.dist-info}/licenses/LICENSE +0 -0
  51. {gamesentenceminer-2.17.7.dist-info → gamesentenceminer-2.18.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,215 @@
1
+ import os
2
+ import subprocess
3
+ import json
4
+ import tkinter as tk
5
+ from tkinter import messagebox
6
+ import ttkbootstrap as ttk
7
+ from PIL import Image, ImageTk
8
+
9
+ from GameSentenceMiner.util.gsm_utils import sanitize_filename
10
+ from GameSentenceMiner.util.configuration import get_temporary_directory, logger, ffmpeg_base_command_list, get_ffprobe_path
11
+
12
+
13
+ class ScreenshotSelectorDialog(tk.Toplevel):
14
+ """
15
+ A modal dialog that extracts frames from a video around a specific timestamp
16
+ and allows the user to select the best one.
17
+ """
18
+ def __init__(self, parent, config_app, video_path, timestamp, mode='beginning'):
19
+ super().__init__(parent)
20
+ self.config_app = config_app
21
+
22
+ self.title("Select Screenshot")
23
+ self.configure(bg="black")
24
+ self.selected_path = None # This will store the final result
25
+ self.parent_window = parent # Store a reference to the parent
26
+
27
+ # Handle the user closing the window with the 'X' button
28
+ self.protocol("WM_DELETE_WINDOW", self._on_cancel)
29
+
30
+ # Make the dialog modal
31
+ self.grab_set()
32
+
33
+ # --- Show a loading message while ffmpeg runs ---
34
+ self.loading_label = ttk.Label(
35
+ self,
36
+ text="Extracting frames, please wait...",
37
+ bootstyle="inverse-primary",
38
+ font=("Helvetica", 16)
39
+ )
40
+ self.loading_label.pack(pady=50, padx=50)
41
+ self.update() # Force the UI to update and show the label
42
+
43
+ # --- Run extraction and build the main UI ---
44
+ try:
45
+ image_paths, golden_frame = self._extract_frames(video_path, timestamp, mode)
46
+ self.loading_label.destroy() # Remove the loading message
47
+
48
+ if not image_paths:
49
+ messagebox.showerror("Error", "Failed to extract frames from the video.", parent=self)
50
+ self.destroy()
51
+ return
52
+
53
+ self._build_image_grid(image_paths, golden_frame)
54
+
55
+ except Exception as e:
56
+ logger.error(f"ScreenshotSelector failed: {e}")
57
+ messagebox.showerror("Error", f"An unexpected error occurred: {e}", parent=self)
58
+ self.destroy()
59
+ return
60
+
61
+ # --- Center the dialog and wait for it to close ---
62
+ self._center_window()
63
+ self.attributes('-topmost', True)
64
+ self.wait_window(self)
65
+ # Force always on top to ensure visibility
66
+
67
+ def _extract_frames(self, video_path, timestamp, mode):
68
+ """Extracts frames using ffmpeg. Encapsulated from the original script."""
69
+ temp_dir = os.path.join(
70
+ get_temporary_directory(False),
71
+ "screenshot_frames",
72
+ sanitize_filename(os.path.splitext(os.path.basename(video_path))[0])
73
+ )
74
+ os.makedirs(temp_dir, exist_ok=True)
75
+
76
+ frame_paths = []
77
+ golden_frame = None
78
+ timestamp_number = float(timestamp)
79
+ video_duration = self.get_video_duration(video_path)
80
+
81
+ if mode == 'middle':
82
+ timestamp_number = max(0.0, timestamp_number - 2.5)
83
+ elif mode == 'end':
84
+ timestamp_number = max(0.0, timestamp_number - 5.0)
85
+
86
+ if video_duration is not None and timestamp_number > video_duration:
87
+ logger.warning(f"Timestamp {timestamp_number} exceeds video duration {video_duration}.")
88
+ return [], None
89
+
90
+ try:
91
+ command = ffmpeg_base_command_list + [
92
+ "-y",
93
+ "-ss", str(timestamp_number),
94
+ "-i", video_path,
95
+ "-vf", f"fps=1/{0.25}",
96
+ "-vframes", "20",
97
+ os.path.join(temp_dir, "frame_%02d.png")
98
+ ]
99
+ subprocess.run(command, check=True, capture_output=True, text=True)
100
+
101
+ for i in range(1, 21):
102
+ frame_path = os.path.join(temp_dir, f"frame_{i:02d}.png")
103
+ if os.path.exists(frame_path):
104
+ frame_paths.append(frame_path)
105
+
106
+ if not frame_paths: return [], None
107
+
108
+ if mode == "beginning":
109
+ golden_frame = frame_paths[0] if frame_paths else None
110
+ elif mode == "middle":
111
+ golden_frame = frame_paths[len(frame_paths) // 2] if frame_paths else None
112
+ elif mode == "end":
113
+ golden_frame = frame_paths[-1] if frame_paths else None
114
+
115
+ return frame_paths, golden_frame
116
+
117
+ except subprocess.CalledProcessError as e:
118
+ logger.error(f"Error extracting frames: {e}")
119
+ logger.error(f"FFmpeg command was: {' '.join(command)}")
120
+ logger.error(f"FFmpeg output:\n{e.stderr}")
121
+ return [], None
122
+ except Exception as e:
123
+ logger.error(f"An unexpected error occurred during frame extraction: {e}")
124
+ return [], None
125
+
126
+ def _build_image_grid(self, image_paths, golden_frame):
127
+ """Creates and displays the grid of selectable images."""
128
+ self.images = [] # Keep a reference to images to prevent garbage collection
129
+ max_cols = 5
130
+ for i, path in enumerate(image_paths):
131
+ try:
132
+ img = Image.open(path)
133
+ # Use a larger thumbnail size for better visibility
134
+ # Making this division-based can be risky if images are very small
135
+ # Let's use a fixed thumbnail size for robustness
136
+ img.thumbnail((256, 144))
137
+ img_tk = ImageTk.PhotoImage(img)
138
+ self.images.append(img_tk)
139
+
140
+ is_golden = (path == golden_frame)
141
+ border_width = 4 if is_golden else 2
142
+ border_color = "gold" if is_golden else "grey"
143
+
144
+ # Using a Frame for better border control
145
+ frame = tk.Frame(self, bg=border_color, borderwidth=border_width, relief="solid")
146
+ frame.grid(row=i // max_cols, column=i % max_cols, padx=3, pady=3)
147
+
148
+ label = tk.Label(frame, image=img_tk, borderwidth=0, bg="black")
149
+ label.pack()
150
+
151
+ # Bind the click event to both the frame and the label for better UX
152
+ frame.bind("<Button-1>", lambda e, p=path: self._on_image_click(p))
153
+ label.bind("<Button-1>", lambda e, p=path: self._on_image_click(p))
154
+
155
+ except Exception as e:
156
+ logger.error(f"Could not load image {path}: {e}")
157
+ error_label = ttk.Label(self, text="Load Error", bootstyle="inverse-danger", width=30, anchor="center")
158
+ error_label.grid(row=i // max_cols, column=i % max_cols, padx=3, pady=3, ipadx=10, ipady=50)
159
+
160
+ def _on_image_click(self, path):
161
+ """Handles a user clicking on an image."""
162
+ self.selected_path = path
163
+ self.destroy()
164
+
165
+ def _on_cancel(self):
166
+ """Handles the user closing the window without a selection."""
167
+ self.selected_path = None
168
+ self.destroy()
169
+
170
+ def _center_window(self):
171
+ """
172
+ Smarter centering logic. Centers on the parent if it's visible,
173
+ otherwise centers on the screen.
174
+ """
175
+ self.update_idletasks()
176
+
177
+ parent = self.parent_window
178
+ dialog_width = self.winfo_width()
179
+ dialog_height = self.winfo_height()
180
+
181
+ if parent.state() == 'withdrawn':
182
+ # PARENT IS HIDDEN: Center the dialog on the screen
183
+ screen_width = self.winfo_screenwidth()
184
+ screen_height = self.winfo_screenheight()
185
+ x = (screen_width // 2) - (dialog_width // 2)
186
+ y = (screen_height // 2) - (dialog_height // 2)
187
+ else:
188
+ # PARENT IS VISIBLE: Center relative to the parent window
189
+ self.transient(parent) # Associate dialog with its parent
190
+ parent_x = parent.winfo_x()
191
+ parent_y = parent.winfo_y()
192
+ parent_width = parent.winfo_width()
193
+ parent_height = parent.winfo_height()
194
+ x = parent_x + (parent_width // 2) - (dialog_width // 2)
195
+ y = parent_y + (parent_height // 2) - (dialog_height // 2)
196
+
197
+ self.geometry(f'+{x}+{y}')
198
+
199
+ def get_video_duration(self, file_path):
200
+ try:
201
+ ffprobe_command = [
202
+ f"{get_ffprobe_path()}",
203
+ "-v", "error",
204
+ "-show_entries", "format=duration",
205
+ "-of", "json",
206
+ file_path
207
+ ]
208
+ logger.debug(" ".join(ffprobe_command))
209
+ result = subprocess.run(ffprobe_command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=True)
210
+ duration_info = json.loads(result.stdout)
211
+ logger.debug(f"Video duration: {duration_info}")
212
+ return float(duration_info["format"]["duration"])
213
+ except (subprocess.CalledProcessError, json.JSONDecodeError, KeyError, FileNotFoundError) as e:
214
+ logger.error(f"Failed to get video duration for {file_path}: {e}")
215
+ return None
@@ -4,10 +4,13 @@ import logging
4
4
  import os
5
5
  import shutil
6
6
  import threading
7
+ import inspect
8
+
7
9
  from dataclasses import dataclass, field
8
10
  from logging.handlers import RotatingFileHandler
9
11
  from os.path import expanduser
10
12
  from sys import platform
13
+ import time
11
14
  from typing import List, Dict
12
15
  import sys
13
16
  from enum import Enum
@@ -19,6 +22,7 @@ from importlib import metadata
19
22
 
20
23
 
21
24
 
25
+
22
26
  OFF = 'OFF'
23
27
  # VOSK = 'VOSK'
24
28
  SILERO = 'SILERO'
@@ -355,7 +359,7 @@ def get_current_version():
355
359
  version = metadata.version(PACKAGE_NAME)
356
360
  return version
357
361
  except metadata.PackageNotFoundError:
358
- return None
362
+ return ""
359
363
 
360
364
 
361
365
  def get_latest_version():
@@ -429,6 +433,7 @@ class Paths:
429
433
  @dataclass
430
434
  class Anki:
431
435
  update_anki: bool = True
436
+ show_update_confirmation_dialog: bool = False
432
437
  url: str = 'http://127.0.0.1:8765'
433
438
  sentence_field: str = "Sentence"
434
439
  sentence_audio_field: str = "SentenceAudio"
@@ -464,6 +469,7 @@ class Features:
464
469
  open_anki_in_browser: bool = True
465
470
  browser_query: str = ''
466
471
  backfill_audio: bool = False
472
+ generate_longplay: bool = False
467
473
 
468
474
 
469
475
  @dataclass_json
@@ -529,12 +535,16 @@ class Audio:
529
535
  class OBS:
530
536
  open_obs: bool = True
531
537
  close_obs: bool = True
538
+ automatically_manage_replay_buffer: bool = True
532
539
  host: str = "127.0.0.1"
533
540
  port: int = 7274
534
541
  password: str = "your_password"
535
542
  get_game_from_scene: bool = True
536
543
  minimum_replay_size: int = 0
537
- turn_off_output_check: bool = False
544
+
545
+ def __post__init__(self):
546
+ # Force get_game_from_scene to be True
547
+ self.get_game_from_scene = True
538
548
 
539
549
 
540
550
  @dataclass_json
@@ -554,7 +564,7 @@ class VAD:
554
564
  language: str = 'ja'
555
565
  # vosk_url: str = VOSK_BASE
556
566
  selected_vad_model: str = WHISPER
557
- backup_vad_model: str = SILERO
567
+ backup_vad_model: str = OFF
558
568
  trim_beginning: bool = False
559
569
  beginning_offset: float = -0.25
560
570
  add_audio_on_no_results: bool = False
@@ -565,6 +575,10 @@ class VAD:
565
575
  use_cpu_for_inference: bool = False
566
576
  use_vad_filter_for_whisper: bool = True
567
577
 
578
+ def __post_init__(self):
579
+ if self.selected_vad_model == self.backup_vad_model:
580
+ self.backup_vad_model = OFF
581
+
568
582
  def is_silero(self):
569
583
  return self.selected_vad_model == SILERO or self.backup_vad_model == SILERO
570
584
 
@@ -797,6 +811,9 @@ class StatsConfig:
797
811
  reading_hours_target: int = 1500 # Target reading hours based on TMW N1 achievement data
798
812
  character_count_target: int = 25000000 # Target character count (25M) inspired by Discord server milestones
799
813
  games_target: int = 100 # Target VNs/games completed based on Refold community standards
814
+ reading_hours_target_date: str = "" # Target date for reading hours goal (ISO format: YYYY-MM-DD)
815
+ character_count_target_date: str = "" # Target date for character count goal (ISO format: YYYY-MM-DD)
816
+ games_target_date: str = "" # Target date for games/VNs goal (ISO format: YYYY-MM-DD)
800
817
 
801
818
  @dataclass_json
802
819
  @dataclass
@@ -806,6 +823,7 @@ class Config:
806
823
  switch_to_default_if_not_found: bool = True
807
824
  locale: str = Locale.English.value
808
825
  stats: StatsConfig = field(default_factory=StatsConfig)
826
+ version: str = ""
809
827
 
810
828
  @classmethod
811
829
  def new(cls):
@@ -842,6 +860,26 @@ class Config:
842
860
  self.stats.session_gap_seconds = profile.advanced.session_gap_seconds
843
861
  if profile.advanced.streak_requirement_hours != default_stats.streak_requirement_hours:
844
862
  self.stats.streak_requirement_hours = profile.advanced.streak_requirement_hours
863
+
864
+ # Add a way to migrate certain things based on version if needed, also help with better defaults
865
+ if self.version:
866
+ if self.version != get_current_version():
867
+ logger.info(f"Config version mismatch detected: {self.version} != {get_current_version()}")
868
+ # Handle version mismatch
869
+ changed = False
870
+ if self.version < "2.18.0":
871
+ changed = True
872
+ # Example, doesn't need to be done
873
+ for profile in self.configs.values():
874
+ profile.obs.get_game_from_scene = True
875
+ # Whisper basically uses Silero's VAD internally, so no need for backup
876
+ if profile.vad.selected_vad_model == WHISPER and profile.vad.backup_vad_model == SILERO:
877
+ profile.vad.backup_vad_model = OFF
878
+
879
+ if changed:
880
+ self.save()
881
+
882
+ self.version = get_current_version()
845
883
 
846
884
  def save(self):
847
885
  with open(get_config_path(), 'w') as file:
@@ -1000,8 +1038,40 @@ def get_app_directory():
1000
1038
  return config_dir
1001
1039
 
1002
1040
 
1041
+ def get_logger_name():
1042
+ """Determine the appropriate logger name based on the calling context."""
1043
+ frame = inspect.currentframe()
1044
+ try:
1045
+ # Go up the call stack to find the main module
1046
+ while frame:
1047
+ filename = frame.f_code.co_filename
1048
+ if filename.endswith(('gsm.py', 'gamesentenceminer.py', '__main__.py')):
1049
+ return "GameSentenceMiner"
1050
+ elif 'ocr' in filename.lower():
1051
+ return "misc_ocr_utils"
1052
+ elif 'overlay' in filename.lower():
1053
+ return "GSM_Overlay"
1054
+ frame = frame.f_back
1055
+
1056
+ # Fallback: check the main module name
1057
+ main_module = inspect.getmodule(inspect.stack()[-1][0])
1058
+ if main_module and hasattr(main_module, '__file__'):
1059
+ main_file = os.path.basename(main_module.__file__)
1060
+ if main_file in ('gsm.py', 'gamesentenceminer.py'):
1061
+ return "GameSentenceMiner"
1062
+ elif 'ocr' in main_file.lower():
1063
+ return "misc_ocr_utils"
1064
+ elif 'overlay' in main_file.lower():
1065
+ return "GSM_Overlay"
1066
+
1067
+ return "GameSentenceMiner" # Default fallback
1068
+ finally:
1069
+ del frame
1070
+
1071
+ logger_name = get_logger_name()
1072
+
1003
1073
  def get_log_path():
1004
- path = os.path.join(get_app_directory(), "logs", 'gamesentenceminer.log')
1074
+ path = os.path.join(get_app_directory(), "logs", f'{logger_name.lower()}.log')
1005
1075
  os.makedirs(os.path.dirname(path), exist_ok=True)
1006
1076
  return path
1007
1077
 
@@ -1139,7 +1209,7 @@ def switch_profile_and_save(profile_name):
1139
1209
  sys.stdout.reconfigure(encoding='utf-8')
1140
1210
  sys.stderr.reconfigure(encoding='utf-8')
1141
1211
 
1142
- logger = logging.getLogger("GameSentenceMiner")
1212
+ logger = logging.getLogger(logger_name)
1143
1213
  # Set the base level to DEBUG so that all messages are captured
1144
1214
  logger.setLevel(logging.DEBUG)
1145
1215
  formatter = logging.Formatter(
@@ -1154,27 +1224,47 @@ console_handler.setFormatter(formatter)
1154
1224
  logger.addHandler(console_handler)
1155
1225
 
1156
1226
  file_path = get_log_path()
1157
- try:
1158
- if os.path.exists(file_path) and os.path.getsize(file_path) > 1 * 1024 * 1024 and os.access(file_path, os.W_OK):
1159
- old_log_path = os.path.join(os.path.dirname(
1160
- file_path), "gamesentenceminer_old.log")
1161
- if os.path.exists(old_log_path):
1162
- os.remove(old_log_path)
1163
- shutil.move(file_path, old_log_path)
1164
- except Exception as e:
1165
- logger.info(
1166
- "Couldn't rotate log, probably because the file is being written to by another process. NOT AN ERROR")
1167
-
1168
- file_handler = logging.FileHandler(file_path, encoding='utf-8')
1169
- file_handler.setLevel(logging.DEBUG)
1170
- file_handler.setFormatter(formatter)
1171
- logger.addHandler(file_handler)
1227
+ # Use RotatingFileHandler for automatic log rotation
1228
+ rotating_handler = RotatingFileHandler(
1229
+ file_path,
1230
+ maxBytes=10 * 1024 * 1024, # 10MB
1231
+ backupCount=5 if logger_name == "GameSentenceMiner" else 0, # Keep more logs for OCR and Overlay
1232
+ encoding='utf-8'
1233
+ )
1234
+ rotating_handler.setLevel(logging.DEBUG)
1235
+ rotating_handler.setFormatter(formatter)
1236
+ logger.addHandler(rotating_handler)
1172
1237
 
1173
1238
  DB_PATH = os.path.join(get_app_directory(), 'gsm.db')
1174
1239
 
1175
1240
 
1241
+ # Clean up files in log directory older than 7 days
1242
+ def cleanup_old_logs(days=7):
1243
+ log_dir = os.path.dirname(get_log_path())
1244
+ now = time.time()
1245
+ cutoff = now - (days * 86400) # 86400 seconds in a day
1246
+
1247
+ if os.path.exists(log_dir):
1248
+ for filename in os.listdir(log_dir):
1249
+ file_path = os.path.join(log_dir, filename)
1250
+ if os.path.isfile(file_path):
1251
+ file_modified = os.path.getmtime(file_path)
1252
+ if file_modified < cutoff:
1253
+ try:
1254
+ os.remove(file_path)
1255
+ logger.info(f"Deleted old log file: {file_path}")
1256
+ except Exception as e:
1257
+ logger.error(f"Error deleting file {file_path}: {e}")
1258
+
1259
+ try:
1260
+ cleanup_old_logs()
1261
+ except Exception as e:
1262
+ logger.warning(f"Error during log cleanup: {e}")
1263
+
1264
+
1176
1265
  class GsmAppState:
1177
1266
  def __init__(self):
1267
+ self.config_app = None
1178
1268
  self.line_for_audio = None
1179
1269
  self.line_for_screenshot = None
1180
1270
  self.anki_note_for_screenshot = None
@@ -1184,11 +1274,15 @@ class GsmAppState:
1184
1274
  self.previous_audio = None
1185
1275
  self.previous_screenshot = None
1186
1276
  self.previous_replay = None
1277
+ self.current_replay = None
1187
1278
  self.lock = threading.Lock()
1188
1279
  self.last_mined_line = None
1189
1280
  self.keep_running = True
1190
1281
  self.current_game = ''
1191
1282
  self.videos_to_remove = set()
1283
+ self.recording_started_time = None
1284
+ self.current_srt = None
1285
+ self.srt_index = 1
1192
1286
 
1193
1287
 
1194
1288
  @dataclass_json
@@ -1237,8 +1331,6 @@ def is_running_from_source():
1237
1331
  while project_root != os.path.dirname(project_root): # Avoid infinite loop
1238
1332
  if os.path.isdir(os.path.join(project_root, '.git')):
1239
1333
  return True
1240
- if os.path.isfile(os.path.join(project_root, 'pyproject.toml')):
1241
- return True
1242
1334
  project_root = os.path.dirname(project_root)
1243
1335
  return False
1244
1336
 
@@ -1250,5 +1342,15 @@ is_dev = is_running_from_source()
1250
1342
 
1251
1343
  is_beangate = os.path.exists("C:/Users/Beangate")
1252
1344
 
1345
+
1346
+ def get_ffmpeg_path():
1347
+ return os.path.join(get_app_directory(), "ffmpeg", "ffmpeg.exe") if is_windows() else "ffmpeg"
1348
+
1349
+ def get_ffprobe_path():
1350
+ return os.path.join(get_app_directory(), "ffmpeg", "ffprobe.exe") if is_windows() else "ffprobe"
1351
+
1352
+ ffmpeg_base_command_list = [get_ffmpeg_path(), "-hide_banner", "-loglevel", "error", '-nostdin']
1353
+
1354
+
1253
1355
  # logger.debug(f"Running in development mode: {is_dev}")
1254
1356
  # logger.debug(f"Running on Beangate's PC: {is_beangate}")
@@ -110,6 +110,11 @@ class SQLiteDBTable:
110
110
  pk_def = f"{cls._pk} TEXT PRIMARY KEY" if not cls._auto_increment else f"{cls._pk} INTEGER PRIMARY KEY AUTOINCREMENT"
111
111
  create_table_sql = f"CREATE TABLE IF NOT EXISTS {cls._table} ({pk_def}, {fields_def})"
112
112
  db.create_table(create_table_sql)
113
+ # Check for missing columns and add them
114
+ existing_columns = [col[1] for col in db.fetchall(f"PRAGMA table_info({cls._table})")]
115
+ for field in cls._fields:
116
+ if field not in existing_columns:
117
+ db.execute(f"ALTER TABLE {cls._table} ADD COLUMN {field} TEXT", commit=True)
113
118
 
114
119
  @classmethod
115
120
  def all(cls: Type[T]) -> List[T]:
@@ -135,15 +140,17 @@ class SQLiteDBTable:
135
140
  fields = [cls._pk] + cls._fields
136
141
  for i, field in enumerate(fields):
137
142
  if i == 0 and field == cls._pk:
138
- if cls._types[i] == int:
143
+ if cls._types[i] is int:
139
144
  setattr(obj, field, int(row[i])
140
145
  if row[i] is not None else None)
141
- elif cls._types[i] == str:
146
+ elif cls._types[i] is str:
142
147
  setattr(obj, field, str(row[i])
143
148
  if row[i] is not None else None)
144
149
  continue
145
- if cls._types[i] == str:
146
- if (row[i].startswith('[') or row[i].startswith('{')):
150
+ if cls._types[i] is str:
151
+ if not row[i]:
152
+ setattr(obj, field, "")
153
+ elif (row[i].startswith('[') or row[i].startswith('{')):
147
154
  try:
148
155
  setattr(obj, field, json.loads(row[i]))
149
156
  except json.JSONDecodeError:
@@ -151,21 +158,21 @@ class SQLiteDBTable:
151
158
  else:
152
159
  setattr(obj, field, str(row[i])
153
160
  if row[i] is not None else None)
154
- elif cls._types[i] == list:
161
+ elif cls._types[i] is list:
155
162
  try:
156
163
  setattr(obj, field, json.loads(row[i]) if row[i] else [])
157
164
  except json.JSONDecodeError:
158
165
  setattr(obj, field, [])
159
- elif cls._types[i] == int:
166
+ elif cls._types[i] is int:
160
167
  setattr(obj, field, int(row[i])
161
168
  if row[i] is not None else None)
162
- elif cls._types[i] == float:
169
+ elif cls._types[i] is float:
163
170
  setattr(obj, field, float(row[i])
164
171
  if row[i] is not None else None)
165
- elif cls._types[i] == bool:
172
+ elif cls._types[i] is bool:
166
173
  setattr(obj, field, bool(row[i])
167
174
  if row[i] is not None else None)
168
- elif cls._types[i] == dict:
175
+ elif cls._types[i] is dict:
169
176
  try:
170
177
  setattr(obj, field, json.loads(row[i]) if row[i] else {})
171
178
  except json.JSONDecodeError:
@@ -212,7 +219,7 @@ class SQLiteDBTable:
212
219
  def add(self, retry=1):
213
220
  try:
214
221
  pk_val = getattr(self, self._pk, None)
215
- if cls._auto_increment:
222
+ if self._auto_increment:
216
223
  self.save()
217
224
  elif pk_val is None:
218
225
  raise ValueError(
@@ -378,9 +385,9 @@ class AIModelsTable(SQLiteDBTable):
378
385
  class GameLinesTable(SQLiteDBTable):
379
386
  _table = 'game_lines'
380
387
  _fields = ['game_name', 'line_text', 'screenshot_in_anki',
381
- 'audio_in_anki', 'screenshot_path', 'audio_path', 'replay_path', 'translation', 'timestamp']
388
+ 'audio_in_anki', 'screenshot_path', 'audio_path', 'replay_path', 'translation', 'timestamp', 'original_game_name']
382
389
  _types = [str, # Includes primary key type
383
- str, str, str, str, str, str, str, str, float]
390
+ str, str, str, str, str, str, str, str, float, str]
384
391
  _pk = 'id'
385
392
  _auto_increment = False # Use string IDs
386
393
 
@@ -394,7 +401,8 @@ class GameLinesTable(SQLiteDBTable):
394
401
  screenshot_path: Optional[str] = None,
395
402
  audio_path: Optional[str] = None,
396
403
  replay_path: Optional[str] = None,
397
- translation: Optional[str] = None):
404
+ translation: Optional[str] = None,
405
+ original_game_name: Optional[str] = None):
398
406
  self.id = id
399
407
  self.game_name = game_name
400
408
  self.line_text = line_text
@@ -406,6 +414,7 @@ class GameLinesTable(SQLiteDBTable):
406
414
  self.audio_path = audio_path if audio_path is not None else ''
407
415
  self.replay_path = replay_path if replay_path is not None else ''
408
416
  self.translation = translation if translation is not None else ''
417
+ self.original_game_name = original_game_name if original_game_name is not None else ''
409
418
 
410
419
  @classmethod
411
420
  def get_all_lines_for_scene(cls, game_name: str) -> List['GameLinesTable']:
@@ -7,8 +7,8 @@ import platform
7
7
  import zipfile
8
8
 
9
9
  from GameSentenceMiner.util.downloader.Untitled_json import scenes
10
- from GameSentenceMiner.util.configuration import get_app_directory, logger
11
- from GameSentenceMiner.util.ffmpeg import get_ffmpeg_path, get_ffprobe_path
10
+ from GameSentenceMiner.util.configuration import get_app_directory, get_ffmpeg_path, logger
11
+ from GameSentenceMiner.util.configuration import get_ffprobe_path
12
12
  from GameSentenceMiner.obs import get_obs_path
13
13
  import tempfile
14
14