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.
- GameSentenceMiner/ai/ai_prompting.py +6 -6
- GameSentenceMiner/anki.py +236 -152
- GameSentenceMiner/gametext.py +7 -4
- GameSentenceMiner/gsm.py +49 -10
- GameSentenceMiner/locales/en_us.json +7 -3
- GameSentenceMiner/locales/ja_jp.json +8 -4
- GameSentenceMiner/locales/zh_cn.json +8 -4
- GameSentenceMiner/obs.py +238 -59
- GameSentenceMiner/ocr/owocr_helper.py +1 -1
- GameSentenceMiner/tools/ss_selector.py +7 -8
- GameSentenceMiner/ui/__init__.py +0 -0
- GameSentenceMiner/ui/anki_confirmation.py +187 -0
- GameSentenceMiner/{config_gui.py → ui/config_gui.py} +100 -35
- GameSentenceMiner/ui/screenshot_selector.py +215 -0
- GameSentenceMiner/util/configuration.py +124 -22
- GameSentenceMiner/util/db.py +22 -13
- GameSentenceMiner/util/downloader/download_tools.py +2 -2
- GameSentenceMiner/util/ffmpeg.py +24 -30
- GameSentenceMiner/util/get_overlay_coords.py +34 -34
- GameSentenceMiner/util/gsm_utils.py +31 -1
- GameSentenceMiner/util/text_log.py +11 -9
- GameSentenceMiner/vad.py +31 -12
- GameSentenceMiner/web/database_api.py +742 -123
- GameSentenceMiner/web/static/css/dashboard-shared.css +241 -0
- GameSentenceMiner/web/static/css/kanji-grid.css +94 -2
- GameSentenceMiner/web/static/css/overview.css +850 -0
- GameSentenceMiner/web/static/css/popups-shared.css +126 -0
- GameSentenceMiner/web/static/css/shared.css +97 -0
- GameSentenceMiner/web/static/css/stats.css +192 -597
- GameSentenceMiner/web/static/js/anki_stats.js +6 -4
- GameSentenceMiner/web/static/js/database.js +209 -5
- GameSentenceMiner/web/static/js/goals.js +610 -0
- GameSentenceMiner/web/static/js/kanji-grid.js +267 -4
- GameSentenceMiner/web/static/js/overview.js +1176 -0
- GameSentenceMiner/web/static/js/shared.js +25 -0
- GameSentenceMiner/web/static/js/stats.js +154 -1459
- GameSentenceMiner/web/stats.py +2 -2
- GameSentenceMiner/web/templates/anki_stats.html +5 -0
- GameSentenceMiner/web/templates/components/navigation.html +3 -1
- GameSentenceMiner/web/templates/database.html +73 -1
- GameSentenceMiner/web/templates/goals.html +376 -0
- GameSentenceMiner/web/templates/index.html +13 -11
- GameSentenceMiner/web/templates/overview.html +416 -0
- GameSentenceMiner/web/templates/stats.html +46 -251
- GameSentenceMiner/web/texthooking_page.py +18 -0
- {gamesentenceminer-2.17.7.dist-info → gamesentenceminer-2.18.0.dist-info}/METADATA +5 -1
- {gamesentenceminer-2.17.7.dist-info → gamesentenceminer-2.18.0.dist-info}/RECORD +51 -41
- {gamesentenceminer-2.17.7.dist-info → gamesentenceminer-2.18.0.dist-info}/WHEEL +0 -0
- {gamesentenceminer-2.17.7.dist-info → gamesentenceminer-2.18.0.dist-info}/entry_points.txt +0 -0
- {gamesentenceminer-2.17.7.dist-info → gamesentenceminer-2.18.0.dist-info}/licenses/LICENSE +0 -0
- {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
|
|
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
|
-
|
|
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 =
|
|
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", '
|
|
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(
|
|
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
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
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}")
|
GameSentenceMiner/util/db.py
CHANGED
|
@@ -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]
|
|
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]
|
|
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]
|
|
146
|
-
if
|
|
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]
|
|
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]
|
|
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]
|
|
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]
|
|
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]
|
|
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
|
|
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.
|
|
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
|
|