GameSentenceMiner 2.9.5__py3-none-any.whl → 2.9.7__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,177 @@
1
+ from dataclasses import dataclass
2
+ from typing import Optional, List
3
+
4
+ from dataclasses_json import dataclass_json
5
+
6
+ from GameSentenceMiner.util.configuration import get_config, logger, save_current_config
7
+
8
+
9
+ # OBS
10
+ @dataclass_json
11
+ @dataclass
12
+ class SceneInfo:
13
+ currentProgramSceneName: str
14
+ currentProgramSceneUuid: str
15
+ sceneName: str
16
+ sceneUuid: str
17
+
18
+
19
+ @dataclass_json
20
+ @dataclass
21
+ class SceneItemTransform:
22
+ alignment: int
23
+ boundsAlignment: int
24
+ boundsHeight: float
25
+ boundsType: str
26
+ boundsWidth: float
27
+ cropBottom: int
28
+ cropLeft: int
29
+ cropRight: int
30
+ cropToBounds: bool
31
+ cropTop: int
32
+ height: float
33
+ positionX: float
34
+ positionY: float
35
+ rotation: float
36
+ scaleX: float
37
+ scaleY: float
38
+ sourceHeight: float
39
+ sourceWidth: float
40
+ width: float
41
+
42
+
43
+ @dataclass_json
44
+ @dataclass
45
+ class SceneItem:
46
+ inputKind: str
47
+ isGroup: Optional[bool]
48
+ sceneItemBlendMode: str
49
+ sceneItemEnabled: bool
50
+ sceneItemId: int
51
+ sceneItemIndex: int
52
+ sceneItemLocked: bool
53
+ sceneItemTransform: SceneItemTransform
54
+ sourceName: str
55
+ sourceType: str
56
+ sourceUuid: str
57
+
58
+ # def __init__(self, **kwargs):
59
+ # self.inputKind = kwargs['inputKind']
60
+ # self.isGroup = kwargs['isGroup']
61
+ # self.sceneItemBlendMode = kwargs['sceneItemBlendMode']
62
+ # self.sceneItemEnabled = kwargs['sceneItemEnabled']
63
+ # self.sceneItemId = kwargs['sceneItemId']
64
+ # self.sceneItemIndex = kwargs['sceneItemIndex']
65
+ # self.sceneItemLocked = kwargs['sceneItemLocked']
66
+ # self.sceneItemTransform = SceneItemTransform(**kwargs['sceneItemTransform'])
67
+ # self.sourceName = kwargs['sourceName']
68
+ # self.sourceType = kwargs['sourceType']
69
+ # self.sourceUuid = kwargs['sourceUuid']
70
+
71
+
72
+ @dataclass_json
73
+ @dataclass
74
+ class SceneItemsResponse:
75
+ sceneItems: List[SceneItem]
76
+
77
+ # def __init__(self, **kwargs):
78
+ # self.sceneItems = [SceneItem(**item) for item in kwargs['sceneItems']]
79
+
80
+
81
+ @dataclass_json
82
+ @dataclass
83
+ class RecordDirectory:
84
+ recordDirectory: str
85
+
86
+
87
+ @dataclass_json
88
+ @dataclass
89
+ class SceneItemInfo:
90
+ sceneIndex: int
91
+ sceneName: str
92
+ sceneUuid: str
93
+
94
+
95
+ @dataclass_json
96
+ @dataclass
97
+ class SceneListResponse:
98
+ scenes: List[SceneItemInfo]
99
+ currentProgramSceneName: Optional[str] = None
100
+ currentProgramSceneUuid: Optional[str] = None
101
+ currentPreviewSceneName: Optional[str] = None
102
+ currentPreviewSceneUuid: Optional[str] = None
103
+
104
+ #
105
+ # @dataclass_json
106
+ # @dataclass
107
+ # class SourceActive:
108
+ # videoActive: bool
109
+ # videoShowing: bool
110
+
111
+ @dataclass_json
112
+ @dataclass
113
+ class AnkiCard:
114
+ noteId: int
115
+ tags: list[str]
116
+ fields: dict[str, dict[str, str]]
117
+ cards: list[int]
118
+ alternatives = {
119
+ "word_field": ["Front", "Word", "TargetWord", "Expression"],
120
+ "sentence_field": ["Example", "Context", "Back", "Sentence"],
121
+ "picture_field": ["Image", "Visual", "Media", "Picture", "Screenshot", 'AnswerImage'],
122
+ "sentence_audio_field": ["SentenceAudio"]
123
+ }
124
+
125
+ def get_field(self, field_name: str) -> str:
126
+ if self.has_field(field_name):
127
+ return self.fields[field_name]['value']
128
+ else:
129
+ raise ValueError(f"Field '{field_name}' not found in AnkiCard. Please make sure your Anki Field Settings in GSM Match your fields in your Anki Note!")
130
+
131
+ def has_field (self, field_name: str) -> bool:
132
+ return field_name in self.fields
133
+
134
+ def __post_init__(self):
135
+ config = get_config()
136
+ changes_found = False
137
+ if not self.has_field(config.anki.word_field):
138
+ found_alternative_field, field = self.find_field(config.anki.word_field, "word_field")
139
+ if found_alternative_field:
140
+ logger.warning(f"{config.anki.word_field} Not found in Anki Card! Saving alternative field '{field}' for word_field to settings.")
141
+ config.anki.word_field = field
142
+ changes_found = True
143
+
144
+ if not self.has_field(config.anki.sentence_field):
145
+ found_alternative_field, field = self.find_field(config.anki.sentence_field, "sentence_field")
146
+ if found_alternative_field:
147
+ logger.warning(f"{config.anki.sentence_field} Not found in Anki Card! Saving alternative field '{field}' for sentence_field to settings.")
148
+ config.anki.sentence_field = field
149
+ changes_found = True
150
+
151
+ if not self.has_field(config.anki.picture_field):
152
+ found_alternative_field, field = self.find_field(config.anki.picture_field, "picture_field")
153
+ if found_alternative_field:
154
+ logger.warning(f"{config.anki.picture_field} Not found in Anki Card! Saving alternative field '{field}' for picture_field to settings.")
155
+ config.anki.picture_field = field
156
+ changes_found = True
157
+
158
+ if not self.has_field(config.anki.sentence_audio_field):
159
+ found_alternative_field, field = self.find_field(config.anki.sentence_audio_field, "sentence_audio_field")
160
+ if found_alternative_field:
161
+ logger.warning(f"{config.anki.sentence_audio_field} Not found in Anki Card! Saving alternative field '{field}' for sentence_audio_field to settings.")
162
+ config.anki.sentence_audio_field = field
163
+ changes_found = True
164
+
165
+ if changes_found:
166
+ save_current_config(config)
167
+
168
+ def find_field(self, field, field_type):
169
+ if field in self.fields:
170
+ return False, field
171
+
172
+ for alt_field in self.alternatives[field_type]:
173
+ for key in self.fields:
174
+ if alt_field.lower() == key.lower():
175
+ return True, key
176
+
177
+ return False, None
@@ -0,0 +1,124 @@
1
+ import requests
2
+ from plyer import notification
3
+ from GameSentenceMiner.util.configuration import logger, is_windows
4
+
5
+ if is_windows():
6
+ from win10toast import ToastNotifier
7
+
8
+ if is_windows():
9
+ class MyToastNotifier(ToastNotifier):
10
+ def __init__(self):
11
+ super().__init__()
12
+
13
+ def on_destroy(self, hwnd, msg, wparam, lparam):
14
+ super().on_destroy(hwnd, msg, wparam, lparam)
15
+ return 0
16
+
17
+ if is_windows():
18
+ notifier = MyToastNotifier()
19
+ else:
20
+ notifier = notification
21
+
22
+ def open_browser_window(note_id, query=None):
23
+ url = "http://localhost:8765"
24
+ headers = {'Content-Type': 'application/json'}
25
+
26
+ data = {
27
+ "action": "guiBrowse",
28
+ "version": 6,
29
+ "params": {
30
+ "query": f"nid:{note_id}" if not query else query,
31
+ }
32
+ }
33
+
34
+ try:
35
+ response = requests.post(url, json=data, headers=headers)
36
+ if response.status_code == 200:
37
+ logger.info(f"Opened Anki note with ID {note_id}")
38
+ else:
39
+ logger.error(f"Failed to open Anki note with ID {note_id}")
40
+ except Exception as e:
41
+ logger.info(f"Error connecting to AnkiConnect: {e}")
42
+
43
+
44
+ def open_anki_card(note_id):
45
+ url = "http://localhost:8765"
46
+ headers = {'Content-Type': 'application/json'}
47
+
48
+ data = {
49
+ "action": "guiEditNote",
50
+ "version": 6,
51
+ "params": {
52
+ "note": note_id
53
+ }
54
+ }
55
+
56
+ try:
57
+ response = requests.post(url, json=data, headers=headers)
58
+ if response.status_code == 200:
59
+ logger.info(f"Opened Anki note with ID {note_id}")
60
+ else:
61
+ logger.error(f"Failed to open Anki note with ID {note_id}")
62
+ except Exception as e:
63
+ logger.info(f"Error connecting to AnkiConnect: {e}")
64
+
65
+
66
+
67
+ def send_notification(title, message, timeout):
68
+ if is_windows():
69
+ notifier.show_toast(title, message, duration=timeout, threaded=True)
70
+ else:
71
+ notification.notify(
72
+ title=title,
73
+ message=message,
74
+ app_name="GameSentenceMiner",
75
+ timeout=timeout # Notification disappears after 5 seconds
76
+ )
77
+
78
+ def send_note_updated(tango):
79
+ send_notification(
80
+ title="Anki Card Updated",
81
+ message=f"Audio and/or Screenshot added to note: {tango}",
82
+ timeout=5 # Notification disappears after 5 seconds
83
+ )
84
+
85
+ def send_screenshot_updated(tango):
86
+ send_notification(
87
+ title="Anki Card Updated",
88
+ message=f"Screenshot updated on note: {tango}",
89
+ timeout=5 # Notification disappears after 5 seconds
90
+ )
91
+
92
+
93
+ def send_screenshot_saved(path):
94
+ send_notification(
95
+ title="Screenshot Saved",
96
+ message=f"Screenshot saved to : {path}",
97
+ timeout=5 # Notification disappears after 5 seconds
98
+ )
99
+
100
+
101
+ def send_audio_generated_notification(audio_path):
102
+ send_notification(
103
+ title="Audio Trimmed",
104
+ message=f"Audio Trimmed and placed at {audio_path}",
105
+ timeout=5 # Notification disappears after 5 seconds
106
+ )
107
+
108
+
109
+ def send_check_obs_notification(reason):
110
+ send_notification(
111
+ title="OBS Replay Invalid",
112
+ message=f"Check OBS Settings! Reason: {reason}",
113
+ timeout=5 # Notification disappears after 5 seconds
114
+ )
115
+
116
+
117
+ def send_error_no_anki_update():
118
+ send_notification(
119
+ title="Error",
120
+ message=f"Anki Card not updated, Check Console for Reason!",
121
+ timeout=5 # Notification disappears after 5 seconds
122
+ )
123
+
124
+
@@ -0,0 +1,37 @@
1
+ from importlib import metadata
2
+
3
+ import requests
4
+
5
+ from GameSentenceMiner.util.configuration import logger
6
+
7
+ PACKAGE_NAME = "GameSentenceMiner"
8
+
9
+ def get_current_version():
10
+ try:
11
+ version = metadata.version(PACKAGE_NAME)
12
+ return version
13
+ except metadata.PackageNotFoundError:
14
+ return None
15
+
16
+ def get_latest_version():
17
+ try:
18
+ response = requests.get(f"https://pypi.org/pypi/{PACKAGE_NAME}/json")
19
+ latest_version = response.json()["info"]["version"]
20
+ return latest_version
21
+ except Exception as e:
22
+ logger.error(f"Error fetching latest version: {e}")
23
+ return None
24
+
25
+ def check_for_updates(force=False):
26
+ try:
27
+ installed_version = get_current_version()
28
+ latest_version = get_latest_version()
29
+
30
+ if installed_version != latest_version or force:
31
+ logger.info(f"Update available: {installed_version} -> {latest_version}")
32
+ return True, latest_version
33
+ else:
34
+ logger.info("You are already using the latest version.")
35
+ return False, latest_version
36
+ except Exception as e:
37
+ logger.error(f"Error checking for updates: {e}")
@@ -0,0 +1,122 @@
1
+ import tkinter as tk
2
+ from PIL import Image, ImageTk
3
+ import subprocess
4
+ import os
5
+ import sys
6
+
7
+ from GameSentenceMiner.util.gsm_utils import sanitize_filename
8
+ from GameSentenceMiner.util.configuration import get_temporary_directory, logger
9
+ from GameSentenceMiner.util.ffmpeg import ffmpeg_base_command_list
10
+ from GameSentenceMiner.util import ffmpeg
11
+
12
+
13
+ def extract_frames(video_path, timestamp, temp_dir, mode):
14
+ frame_paths = []
15
+ timestamp_number = float(timestamp)
16
+ golden_frame_index = 1 # Default to the first frame
17
+ golden_frame = None
18
+ video_duration = ffmpeg.get_video_duration(video_path)
19
+
20
+ if mode == 'middle':
21
+ timestamp_number = max(0.0, timestamp_number - 2.5)
22
+ elif mode == 'end':
23
+ timestamp_number = max(0.0, timestamp_number - 5.0)
24
+
25
+ if video_duration is not None and timestamp_number > video_duration:
26
+ logger.debug(f"Timestamp {timestamp_number} exceeds video duration {video_duration}.")
27
+ return None
28
+
29
+ try:
30
+ command = ffmpeg_base_command_list + [
31
+ "-y",
32
+ "-ss", str(timestamp_number),
33
+ "-i", video_path,
34
+ "-vf", f"fps=1/{0.25}",
35
+ "-vframes", "20",
36
+ os.path.join(temp_dir, "frame_%02d.png")
37
+ ]
38
+ subprocess.run(command, check=True, capture_output=True)
39
+ for i in range(1, 21):
40
+ if os.path.exists(os.path.join(temp_dir, f"frame_{i:02d}.png")):
41
+ frame_paths.append(os.path.join(temp_dir, f"frame_{i:02d}.png"))
42
+
43
+ if mode == "beginning":
44
+ golden_frame = frame_paths[0]
45
+ if mode == "middle":
46
+ golden_frame = frame_paths[len(frame_paths) // 2]
47
+ if mode == "end":
48
+ golden_frame = frame_paths[-1]
49
+ except subprocess.CalledProcessError as e:
50
+ logger.debug(f"Error extracting frames: {e}")
51
+ logger.debug(f"Command was: {' '.join(command)}")
52
+ logger.debug(f"FFmpeg output:\n{e.stderr.decode()}")
53
+ return None
54
+ except Exception as e:
55
+ logger.debug(f"An error occurred: {e}")
56
+ return None
57
+ return frame_paths, golden_frame
58
+
59
+ def timestamp_to_seconds(timestamp):
60
+ hours, minutes, seconds = map(int, timestamp.split(':'))
61
+ return hours * 3600 + minutes * 60 + seconds
62
+
63
+ def display_images(image_paths, golden_frame):
64
+ window = tk.Tk()
65
+ window.configure(bg="black") # Set the background color to black
66
+ window.title("Image Selector")
67
+ selected_path = tk.StringVar()
68
+ image_widgets = []
69
+
70
+ def on_image_click(event):
71
+ widget = event.widget
72
+ index = image_widgets.index(widget)
73
+ selected_path.set(image_paths[index])
74
+ window.quit()
75
+
76
+ for i, path in enumerate(image_paths):
77
+ img = Image.open(path)
78
+ img.thumbnail((450, 450))
79
+ img_tk = ImageTk.PhotoImage(img)
80
+ if golden_frame and path == golden_frame:
81
+ label = tk.Label(window, image=img_tk, borderwidth=5, relief="solid")
82
+ label.config(highlightbackground="yellow", highlightthickness=5)
83
+ else:
84
+ label = tk.Label(window, image=img_tk)
85
+ label.image = img_tk
86
+ label.grid(row=i // 5, column=i % 5, padx=5, pady=5)
87
+ label.bind("<Button-1>", on_image_click) # Bind click event to the label
88
+ image_widgets.append(label)
89
+
90
+ window.attributes("-topmost", True)
91
+ window.mainloop()
92
+ return selected_path.get()
93
+
94
+ def run_extraction_and_display(video_path, timestamp_str, mode):
95
+ temp_dir = os.path.join(get_temporary_directory(False), "screenshot_frames", sanitize_filename(os.path.splitext(os.path.basename(video_path))[0]))
96
+ os.makedirs(temp_dir, exist_ok=True)
97
+ image_paths, golden_frame = extract_frames(video_path, timestamp_str, temp_dir, mode)
98
+ if image_paths:
99
+ selected_image_path = display_images(image_paths, golden_frame)
100
+ if selected_image_path:
101
+ print(selected_image_path)
102
+ else:
103
+ logger.debug("No image was selected.")
104
+ else:
105
+ logger.debug("Frame extraction failed.")
106
+
107
+ def main():
108
+ # if len(sys.argv) != 3:
109
+ # print("Usage: python script.py <video_path> <timestamp>")
110
+ # sys.exit(1)
111
+ try:
112
+ video_path = sys.argv[1]
113
+ timestamp_str = sys.argv[2]
114
+ mode = sys.argv[3] if len(sys.argv) > 3 else "beginning"
115
+ run_extraction_and_display(video_path, timestamp_str, mode)
116
+ except Exception as e:
117
+ logger.debug(e)
118
+ sys.exit(1)
119
+
120
+
121
+ if __name__ == "__main__":
122
+ main()
@@ -0,0 +1,186 @@
1
+ import uuid
2
+ from dataclasses import dataclass
3
+ from datetime import datetime
4
+ from difflib import SequenceMatcher
5
+ from typing import Optional
6
+
7
+ from GameSentenceMiner.util.gsm_utils import remove_html_and_cloze_tags
8
+ from GameSentenceMiner.util.configuration import logger, get_config
9
+ from GameSentenceMiner.util.model import AnkiCard
10
+
11
+ initial_time = datetime.now()
12
+
13
+
14
+ @dataclass
15
+ class GameLine:
16
+ id: str
17
+ text: str
18
+ time: datetime
19
+ prev: 'GameLine | None'
20
+ next: 'GameLine | None'
21
+ index: int = 0
22
+
23
+ def get_previous_time(self):
24
+ if self.prev:
25
+ return self.prev.time
26
+ return initial_time
27
+
28
+ def get_next_time(self):
29
+ if self.next:
30
+ return self.next.time
31
+ return 0
32
+
33
+ def __str__(self):
34
+ return str({"text": self.text, "time": self.time})
35
+
36
+
37
+ @dataclass
38
+ class GameText:
39
+ values: list[GameLine]
40
+ values_dict: dict[str, GameLine]
41
+ game_line_index = 0
42
+
43
+ def __init__(self):
44
+ self.values = []
45
+ self.values_dict = {}
46
+
47
+ def __getitem__(self, index):
48
+ return self.values[index]
49
+
50
+ def get_by_id(self, line_id: str) -> Optional[GameLine]:
51
+ if not self.values_dict:
52
+ return None
53
+ return self.values_dict.get(line_id)
54
+
55
+ def get_time(self, line_text: str, occurrence: int = -1) -> datetime:
56
+ matches = [line for line in self.values if line.text == line_text]
57
+ if matches:
58
+ return matches[occurrence].time # Default to latest
59
+ return initial_time
60
+
61
+ def get_event(self, line_text: str, occurrence: int = -1) -> GameLine | None:
62
+ matches = [line for line in self.values if line.text == line_text]
63
+ if matches:
64
+ return matches[occurrence]
65
+ return None
66
+
67
+ def add_line(self, line_text, line_time=None):
68
+ if not line_text:
69
+ return
70
+ line_id = str(uuid.uuid1())
71
+ new_line = GameLine(
72
+ id=line_id, # Time-based UUID as an integer
73
+ text=line_text,
74
+ time=line_time if line_time else datetime.now(),
75
+ prev=self.values[-1] if self.values else None,
76
+ next=None,
77
+ index=self.game_line_index
78
+ )
79
+ self.values_dict[line_id] = new_line
80
+ logger.debug(f"Adding line: {new_line}")
81
+ self.game_line_index += 1
82
+ if self.values:
83
+ self.values[-1].next = new_line
84
+ self.values.append(new_line)
85
+ # self.remove_old_events(datetime.now() - timedelta(minutes=10))
86
+
87
+ def has_line(self, line_text) -> bool:
88
+ for game_line in self.values:
89
+ if game_line.text == line_text:
90
+ return True
91
+ return False
92
+
93
+
94
+ text_log = GameText()
95
+
96
+
97
+ def similar(a, b):
98
+ return SequenceMatcher(None, a, b).ratio()
99
+
100
+
101
+ def one_contains_the_other(a, b):
102
+ return a in b or b in a
103
+
104
+
105
+ def lines_match(a, b):
106
+ similarity = similar(a, b)
107
+ logger.debug(f"Comparing: {a} with {b} - Similarity: {similarity}, Or One contains the other: {one_contains_the_other(a, b)}")
108
+ return similar(a, b) >= 0.60 or one_contains_the_other(a, b)
109
+
110
+
111
+ def get_text_event(last_note) -> GameLine:
112
+ lines = text_log.values
113
+
114
+ if not lines:
115
+ raise Exception("No lines in history. Text is required from either clipboard or websocket for GSM to work. Please check your setup/config.")
116
+
117
+ if not last_note:
118
+ return lines[-1]
119
+
120
+ sentence = last_note.get_field(get_config().anki.sentence_field)
121
+ if not sentence:
122
+ return lines[-1]
123
+
124
+ for line in reversed(lines):
125
+ if lines_match(line.text, remove_html_and_cloze_tags(sentence)):
126
+ return line
127
+
128
+ logger.info("Could not find matching sentence from GSM's history. Using the latest line.")
129
+ return lines[-1]
130
+
131
+
132
+ def get_line_and_future_lines(last_note):
133
+ if not last_note:
134
+ return []
135
+
136
+ sentence = last_note.get_field(get_config().anki.sentence_field)
137
+ found_lines = []
138
+ if sentence:
139
+ found = False
140
+ for line in text_log.values:
141
+ if found:
142
+ found_lines.append(line.text)
143
+ if lines_match(line.text, remove_html_and_cloze_tags(sentence)): # 80% similarity threshold
144
+ found = True
145
+ found_lines.append(line.text)
146
+ return found_lines
147
+
148
+
149
+ def get_mined_line(last_note: AnkiCard, lines):
150
+ if not last_note:
151
+ return lines[-1]
152
+ if not lines:
153
+ lines = get_all_lines()
154
+
155
+ sentence = last_note.get_field(get_config().anki.sentence_field)
156
+ for line in lines:
157
+ if lines_match(line.text, remove_html_and_cloze_tags(sentence)):
158
+ return line
159
+ return lines[-1]
160
+
161
+
162
+ def get_time_of_line(line):
163
+ return text_log.get_time(line)
164
+
165
+
166
+ def get_all_lines():
167
+ return text_log.values
168
+
169
+
170
+ def get_text_log() -> GameText:
171
+ return text_log
172
+
173
+ def add_line(current_line_after_regex, line_time):
174
+ text_log.add_line(current_line_after_regex, line_time)
175
+
176
+ def get_line_by_id(line_id: str) -> Optional[GameLine]:
177
+ """
178
+ Retrieve a GameLine by its unique ID.
179
+
180
+ Args:
181
+ line_id (str): The unique identifier of the GameLine.
182
+
183
+ Returns:
184
+ Optional[GameLine]: The GameLine object if found, otherwise None.
185
+ """
186
+ return text_log.get_by_id(line_id)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: GameSentenceMiner
3
- Version: 2.9.5
3
+ Version: 2.9.7
4
4
  Summary: A tool for mining sentences from games.
5
5
  Author-email: Beangate <bpwhelan95@gmail.com>
6
6
  License: MIT License