GameSentenceMiner 2.19.16__py3-none-any.whl → 2.20.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.

Potentially problematic release.


This version of GameSentenceMiner might be problematic. Click here for more details.

Files changed (70) hide show
  1. GameSentenceMiner/__init__.py +39 -0
  2. GameSentenceMiner/anki.py +6 -3
  3. GameSentenceMiner/gametext.py +13 -2
  4. GameSentenceMiner/gsm.py +40 -3
  5. GameSentenceMiner/locales/en_us.json +4 -0
  6. GameSentenceMiner/locales/ja_jp.json +4 -0
  7. GameSentenceMiner/locales/zh_cn.json +4 -0
  8. GameSentenceMiner/obs.py +4 -1
  9. GameSentenceMiner/owocr/owocr/ocr.py +304 -134
  10. GameSentenceMiner/owocr/owocr/run.py +1 -1
  11. GameSentenceMiner/ui/anki_confirmation.py +4 -2
  12. GameSentenceMiner/ui/config_gui.py +12 -0
  13. GameSentenceMiner/util/configuration.py +6 -2
  14. GameSentenceMiner/util/cron/__init__.py +12 -0
  15. GameSentenceMiner/util/cron/daily_rollup.py +613 -0
  16. GameSentenceMiner/util/cron/jiten_update.py +397 -0
  17. GameSentenceMiner/util/cron/populate_games.py +154 -0
  18. GameSentenceMiner/util/cron/run_crons.py +148 -0
  19. GameSentenceMiner/util/cron/setup_populate_games_cron.py +118 -0
  20. GameSentenceMiner/util/cron_table.py +334 -0
  21. GameSentenceMiner/util/db.py +236 -49
  22. GameSentenceMiner/util/ffmpeg.py +23 -4
  23. GameSentenceMiner/util/games_table.py +340 -93
  24. GameSentenceMiner/util/jiten_api_client.py +188 -0
  25. GameSentenceMiner/util/stats_rollup_table.py +216 -0
  26. GameSentenceMiner/web/anki_api_endpoints.py +438 -220
  27. GameSentenceMiner/web/database_api.py +955 -1259
  28. GameSentenceMiner/web/jiten_database_api.py +1015 -0
  29. GameSentenceMiner/web/rollup_stats.py +672 -0
  30. GameSentenceMiner/web/static/css/dashboard-shared.css +75 -13
  31. GameSentenceMiner/web/static/css/overview.css +604 -47
  32. GameSentenceMiner/web/static/css/search.css +226 -0
  33. GameSentenceMiner/web/static/css/shared.css +762 -0
  34. GameSentenceMiner/web/static/css/stats.css +221 -0
  35. GameSentenceMiner/web/static/js/components/bar-chart.js +339 -0
  36. GameSentenceMiner/web/static/js/database-bulk-operations.js +320 -0
  37. GameSentenceMiner/web/static/js/database-game-data.js +390 -0
  38. GameSentenceMiner/web/static/js/database-game-operations.js +213 -0
  39. GameSentenceMiner/web/static/js/database-helpers.js +44 -0
  40. GameSentenceMiner/web/static/js/database-jiten-integration.js +750 -0
  41. GameSentenceMiner/web/static/js/database-popups.js +89 -0
  42. GameSentenceMiner/web/static/js/database-tabs.js +64 -0
  43. GameSentenceMiner/web/static/js/database-text-management.js +371 -0
  44. GameSentenceMiner/web/static/js/database.js +86 -718
  45. GameSentenceMiner/web/static/js/goals.js +79 -18
  46. GameSentenceMiner/web/static/js/heatmap.js +29 -23
  47. GameSentenceMiner/web/static/js/overview.js +1205 -339
  48. GameSentenceMiner/web/static/js/regex-patterns.js +100 -0
  49. GameSentenceMiner/web/static/js/search.js +215 -18
  50. GameSentenceMiner/web/static/js/shared.js +193 -39
  51. GameSentenceMiner/web/static/js/stats.js +1536 -179
  52. GameSentenceMiner/web/stats.py +1142 -269
  53. GameSentenceMiner/web/stats_api.py +2104 -0
  54. GameSentenceMiner/web/templates/anki_stats.html +4 -18
  55. GameSentenceMiner/web/templates/components/date-range.html +118 -3
  56. GameSentenceMiner/web/templates/components/html-head.html +40 -6
  57. GameSentenceMiner/web/templates/components/js-config.html +8 -8
  58. GameSentenceMiner/web/templates/components/regex-input.html +160 -0
  59. GameSentenceMiner/web/templates/database.html +564 -117
  60. GameSentenceMiner/web/templates/goals.html +41 -5
  61. GameSentenceMiner/web/templates/overview.html +159 -129
  62. GameSentenceMiner/web/templates/search.html +78 -9
  63. GameSentenceMiner/web/templates/stats.html +159 -5
  64. GameSentenceMiner/web/texthooking_page.py +280 -111
  65. {gamesentenceminer-2.19.16.dist-info → gamesentenceminer-2.20.0.dist-info}/METADATA +43 -2
  66. {gamesentenceminer-2.19.16.dist-info → gamesentenceminer-2.20.0.dist-info}/RECORD +70 -47
  67. {gamesentenceminer-2.19.16.dist-info → gamesentenceminer-2.20.0.dist-info}/WHEEL +0 -0
  68. {gamesentenceminer-2.19.16.dist-info → gamesentenceminer-2.20.0.dist-info}/entry_points.txt +0 -0
  69. {gamesentenceminer-2.19.16.dist-info → gamesentenceminer-2.20.0.dist-info}/licenses/LICENSE +0 -0
  70. {gamesentenceminer-2.19.16.dist-info → gamesentenceminer-2.20.0.dist-info}/top_level.txt +0 -0
@@ -1,4 +1,6 @@
1
1
  import uuid
2
+ import re
3
+ from difflib import SequenceMatcher
2
4
  from typing import Optional, List, Dict
3
5
 
4
6
  from GameSentenceMiner.util.db import SQLiteDBTable
@@ -6,29 +8,41 @@ from GameSentenceMiner.util.configuration import logger
6
8
 
7
9
 
8
10
  class GamesTable(SQLiteDBTable):
9
- _table = 'games'
11
+ _table = "games"
10
12
  _fields = [
11
- 'deck_id', 'title_original', 'title_romaji', 'title_english',
12
- 'type',
13
- 'description', 'image', 'character_count', 'difficulty', 'links', 'completed',
14
- 'manual_overrides'
13
+ "deck_id",
14
+ "title_original",
15
+ "title_romaji",
16
+ "title_english",
17
+ "type",
18
+ "description",
19
+ "image",
20
+ "character_count",
21
+ "difficulty",
22
+ "links",
23
+ "completed",
24
+ "release_date",
25
+ "manual_overrides",
26
+ "obs_scene_name",
15
27
  ]
16
28
  _types = [
17
- str, # id (primary key)
18
- int, # deck_id
19
- str, # title_original
20
- str, # title_romaji
21
- str, # title_english
22
- str, # type (string)
23
- str, # description
24
- str, # image (base64)
25
- int, # character_count
26
- int, # difficulty
27
- list, # links (stored as JSON)
28
- bool, # completed
29
- list # manual_overrides (stored as JSON)
29
+ str, # id (primary key)
30
+ int, # deck_id
31
+ str, # title_original
32
+ str, # title_romaji
33
+ str, # title_english
34
+ str, # type (string)
35
+ str, # description
36
+ str, # image (base64)
37
+ int, # character_count
38
+ int, # difficulty
39
+ list, # links (stored as JSON)
40
+ bool, # completed
41
+ str, # release_date (ISO date string)
42
+ list, # manual_overrides (stored as JSON)
43
+ str, # obs_scene_name (immutable OBS scene name)
30
44
  ]
31
- _pk = 'id'
45
+ _pk = "id"
32
46
  _auto_increment = False # UUID-based primary key
33
47
 
34
48
  def __init__(
@@ -38,85 +52,235 @@ class GamesTable(SQLiteDBTable):
38
52
  title_original: Optional[str] = None,
39
53
  title_romaji: Optional[str] = None,
40
54
  title_english: Optional[str] = None,
41
- type: Optional[str] = None,
55
+ game_type: Optional[str] = None,
42
56
  description: Optional[str] = None,
43
57
  image: Optional[str] = None,
44
58
  character_count: int = 0,
45
59
  difficulty: Optional[int] = None,
46
60
  links: Optional[List[Dict]] = None,
47
61
  completed: bool = False,
48
- manual_overrides: Optional[List[str]] = None
62
+ release_date: Optional[str] = None,
63
+ manual_overrides: Optional[List[str]] = None,
64
+ obs_scene_name: Optional[str] = None,
49
65
  ):
50
66
  self.id = id if id else str(uuid.uuid4())
51
67
  self.deck_id = deck_id
52
- self.title_original = title_original if title_original else ''
53
- self.title_romaji = title_romaji if title_romaji else ''
54
- self.title_english = title_english if title_english else ''
55
- self.type = type if type else ''
56
- self.description = description if description else ''
57
- self.image = image if image else ''
68
+ self.title_original = title_original if title_original else ""
69
+ self.title_romaji = title_romaji if title_romaji else ""
70
+ self.title_english = title_english if title_english else ""
71
+ self.type = game_type if game_type else ""
72
+ self.description = description if description else ""
73
+ self.image = image if image else ""
58
74
  self.character_count = character_count
59
75
  self.difficulty = difficulty
60
76
  self.links = links if links else []
61
77
  self.completed = completed
78
+ self.release_date = release_date if release_date else ""
62
79
  self.manual_overrides = manual_overrides if manual_overrides else []
80
+ self.obs_scene_name = obs_scene_name if obs_scene_name else ""
63
81
 
64
82
  @classmethod
65
- def get_by_deck_id(cls, deck_id: int) -> Optional['GamesTable']:
83
+ def get_by_deck_id(cls, deck_id: int) -> Optional["GamesTable"]:
66
84
  """Get a game by its jiten.moe deck ID."""
67
85
  row = cls._db.fetchone(
68
- f"SELECT * FROM {cls._table} WHERE deck_id=?", (deck_id,))
86
+ f"SELECT * FROM {cls._table} WHERE deck_id=?", (deck_id,)
87
+ )
69
88
  return cls.from_row(row) if row else None
70
89
 
71
90
  @classmethod
72
- def get_by_title(cls, title_original: str) -> Optional['GamesTable']:
91
+ def get_by_title(cls, title_original: str) -> Optional["GamesTable"]:
73
92
  """Get a game by its original title."""
74
93
  row = cls._db.fetchone(
75
- f"SELECT * FROM {cls._table} WHERE title_original=?", (title_original,))
94
+ f"SELECT * FROM {cls._table} WHERE title_original=?", (title_original,)
95
+ )
76
96
  return cls.from_row(row) if row else None
77
97
 
78
98
  @classmethod
79
- def get_or_create_by_name(cls, game_name: str) -> 'GamesTable':
99
+ def get_by_obs_scene_name(cls, obs_scene_name: str) -> Optional["GamesTable"]:
100
+ """Get a game by its OBS scene name."""
101
+ row = cls._db.fetchone(
102
+ f"SELECT * FROM {cls._table} WHERE obs_scene_name=?", (obs_scene_name,)
103
+ )
104
+ return cls.from_row(row) if row else None
105
+
106
+ @classmethod
107
+ def normalize_game_name(cls, name: str) -> str:
108
+ """
109
+ Normalize a game name for fuzzy matching.
110
+ Removes version numbers, extra whitespace, and converts to lowercase.
111
+ """
112
+ if not name:
113
+ return ""
114
+ # Remove version patterns like "ver1.00", "v1.0", "Ver.1.0", etc.
115
+ normalized = re.sub(
116
+ r"\s*v(?:er)?\.?\s*\d+(?:\.\d+)*", "", name, flags=re.IGNORECASE
117
+ )
118
+ # Remove extra whitespace
119
+ normalized = " ".join(normalized.split())
120
+ # Convert to lowercase for comparison
121
+ return normalized.lower().strip()
122
+
123
+ @classmethod
124
+ def fuzzy_match_game_name(
125
+ cls, name1: str, name2: str, threshold: float = 0.85
126
+ ) -> bool:
127
+ """
128
+ Check if two game names are similar using fuzzy matching.
129
+
130
+ Args:
131
+ name1: First game name
132
+ name2: Second game name
133
+ threshold: Similarity threshold (0.0 to 1.0), default 0.85
134
+
135
+ Returns:
136
+ True if names are similar enough, False otherwise
137
+ """
138
+ if not name1 or not name2:
139
+ return False
140
+
141
+ # Normalize both names
142
+ norm1 = cls.normalize_game_name(name1)
143
+ norm2 = cls.normalize_game_name(name2)
144
+
145
+ # Calculate similarity ratio
146
+ similarity = SequenceMatcher(None, norm1, norm2).ratio()
147
+
148
+ logger.debug(
149
+ f"[FUZZY_MATCH] Comparing '{name1}' vs '{name2}': normalized='{norm1}' vs '{norm2}', similarity={similarity:.2f}, threshold={threshold}"
150
+ )
151
+
152
+ return similarity >= threshold
153
+
154
+ @classmethod
155
+ def find_similar_game(
156
+ cls, game_name: str, threshold: float = 0.85
157
+ ) -> Optional["GamesTable"]:
158
+ """
159
+ Find a game with a similar name using fuzzy matching.
160
+
161
+ Args:
162
+ game_name: The game name to search for
163
+ threshold: Similarity threshold (default 0.85)
164
+
165
+ Returns:
166
+ GamesTable: The similar game if found, None otherwise
167
+ """
168
+ # Get all games
169
+ all_games = cls.all()
170
+
171
+ for game in all_games:
172
+ # Check against title_original
173
+ if cls.fuzzy_match_game_name(game_name, game.title_original, threshold):
174
+ logger.debug(
175
+ f"[FUZZY_MATCH] Found similar game by title_original: '{game_name}' matches '{game.title_original}' (id={game.id})"
176
+ )
177
+ return game
178
+
179
+ # Check against obs_scene_name if it exists
180
+ if game.obs_scene_name and cls.fuzzy_match_game_name(
181
+ game_name, game.obs_scene_name, threshold
182
+ ):
183
+ logger.debug(
184
+ f"[FUZZY_MATCH] Found similar game by obs_scene_name: '{game_name}' matches '{game.obs_scene_name}' (id={game.id})"
185
+ )
186
+ return game
187
+
188
+ return None
189
+
190
+ @classmethod
191
+ def get_or_create_by_name(cls, game_name: str) -> "GamesTable":
80
192
  """
81
193
  Get an existing game by name, or create a new one if it doesn't exist.
82
194
  This is the primary method for automatically linking game_lines to games.
83
-
195
+
84
196
  Args:
85
- game_name: The original game name (from game_lines.game_name)
86
-
197
+ game_name: The original game name (from game_lines.game_name / OBS scene name)
198
+
87
199
  Returns:
88
200
  GamesTable: The existing or newly created game record
89
201
  """
90
- # Try to find existing game
202
+ logger.debug(f"[GET_OR_CREATE] Looking up game: '{game_name}'")
203
+
204
+ # Try exact match on title_original first
91
205
  existing = cls.get_by_title(game_name)
92
206
  if existing:
207
+ logger.debug(
208
+ f"[GET_OR_CREATE] Found exact match in games table: id={existing.id}, deck_id={existing.deck_id}"
209
+ )
93
210
  return existing
94
-
95
- # Create new game with minimal info
211
+
212
+ logger.debug(
213
+ f"[GET_OR_CREATE] No exact match found, checking game_lines for existing mapping..."
214
+ )
215
+
216
+ # Check if existing game_lines already have this game_name mapped to a game_id
217
+ # This handles cases where OBS scene name != game title_original
218
+ from GameSentenceMiner.util.db import GameLinesTable
219
+
220
+ # First, let's see what game_names exist in game_lines
221
+ all_game_names = GameLinesTable._db.fetchall(
222
+ f"SELECT DISTINCT game_name FROM {GameLinesTable._table} LIMIT 10"
223
+ )
224
+ logger.debug(
225
+ f"[GET_OR_CREATE] Sample game_names in game_lines: {[row[0] for row in all_game_names]}"
226
+ )
227
+
228
+ # Now try to find our specific game_name
229
+ existing_line = GameLinesTable._db.fetchone(
230
+ f"SELECT game_id FROM {GameLinesTable._table} WHERE game_name=? AND game_id IS NOT NULL AND game_id != '' LIMIT 1",
231
+ (game_name,),
232
+ )
233
+
234
+ if existing_line and existing_line[0]:
235
+ game_id = existing_line[0]
236
+ logger.debug(
237
+ f"[GET_OR_CREATE] Found existing mapping in game_lines: '{game_name}' -> game_id={game_id}"
238
+ )
239
+ existing_game = cls.get(game_id)
240
+ if existing_game:
241
+ logger.debug(
242
+ f"[GET_OR_CREATE] ✓ Reusing existing game: '{game_name}' -> game_id={game_id} ('{existing_game.title_original}', deck_id={existing_game.deck_id})"
243
+ )
244
+ return existing_game
245
+ else:
246
+ logger.warning(
247
+ f"[GET_OR_CREATE] game_id {game_id} found in game_lines but not in games table!"
248
+ )
249
+ else:
250
+ logger.debug(
251
+ f"[GET_OR_CREATE] No existing mapping found in game_lines for '{game_name}'"
252
+ )
253
+
254
+ # No existing mapping found - create new UNLINKED game with minimal info
255
+ # Store the OBS scene name in obs_scene_name field for future linking
96
256
  new_game = cls(
97
257
  title_original=game_name,
98
- title_romaji='',
99
- title_english='',
100
- description='',
258
+ title_romaji="",
259
+ title_english="",
260
+ description="",
101
261
  difficulty=None,
102
- completed=False
262
+ completed=False,
263
+ obs_scene_name=game_name, # Store original OBS scene name
264
+ )
265
+ new_game.add() # Use add() instead of save() for new records with UUID primary keys
266
+ logger.debug(
267
+ f"[GET_OR_CREATE] ✗ Created new UNLINKED game record: '{game_name}' (id={new_game.id}, obs_scene_name='{game_name}')"
268
+ )
269
+ logger.debug(
270
+ f"[GET_OR_CREATE] ℹ️ This game needs to be manually linked to jiten.moe via the Games Management interface"
103
271
  )
104
- new_game.save()
105
- logger.info(f"Auto-created new game record: {game_name} (id={new_game.id})")
106
272
  return new_game
107
273
 
108
274
  @classmethod
109
- def get_all_completed(cls) -> List['GamesTable']:
275
+ def get_all_completed(cls) -> List["GamesTable"]:
110
276
  """Get all completed games."""
111
- rows = cls._db.fetchall(
112
- f"SELECT * FROM {cls._table} WHERE completed=1")
277
+ rows = cls._db.fetchall(f"SELECT * FROM {cls._table} WHERE completed=1")
113
278
  return [cls.from_row(row) for row in rows]
114
279
 
115
280
  @classmethod
116
- def get_all_in_progress(cls) -> List['GamesTable']:
281
+ def get_all_in_progress(cls) -> List["GamesTable"]:
117
282
  """Get all games that are in progress (not completed)."""
118
- rows = cls._db.fetchall(
119
- f"SELECT * FROM {cls._table} WHERE completed=0")
283
+ rows = cls._db.fetchall(f"SELECT * FROM {cls._table} WHERE completed=0")
120
284
  return [cls.from_row(row) for row in rows]
121
285
 
122
286
  @classmethod
@@ -126,9 +290,10 @@ class GamesTable(SQLiteDBTable):
126
290
  Returns Unix timestamp (float) or None if no lines exist.
127
291
  """
128
292
  from GameSentenceMiner.util.db import GameLinesTable
293
+
129
294
  result = GameLinesTable._db.fetchone(
130
295
  f"SELECT MIN(timestamp) FROM {GameLinesTable._table} WHERE game_id=?",
131
- (game_id,)
296
+ (game_id,),
132
297
  )
133
298
  return result[0] if result and result[0] else None
134
299
 
@@ -139,34 +304,37 @@ class GamesTable(SQLiteDBTable):
139
304
  Returns Unix timestamp (float) or None if no lines exist.
140
305
  """
141
306
  from GameSentenceMiner.util.db import GameLinesTable
307
+
142
308
  result = GameLinesTable._db.fetchone(
143
309
  f"SELECT MAX(timestamp) FROM {GameLinesTable._table} WHERE game_id=?",
144
- (game_id,)
310
+ (game_id,),
145
311
  )
146
312
  return result[0] if result and result[0] else None
147
313
 
148
314
  def is_field_manual(self, field_name: str) -> bool:
149
315
  """
150
316
  Check if a field has been manually edited and should not be auto-updated.
151
-
317
+
152
318
  Args:
153
319
  field_name: The name of the field to check
154
-
320
+
155
321
  Returns:
156
322
  True if the field is manually overridden, False otherwise
157
323
  """
158
324
  return field_name in self.manual_overrides
159
-
325
+
160
326
  def mark_field_manual(self, field_name: str):
161
327
  """
162
328
  Mark a field as manually edited so it won't be auto-updated.
163
-
329
+
164
330
  Args:
165
331
  field_name: The name of the field to mark as manual
166
332
  """
167
333
  if field_name not in self.manual_overrides and field_name in self._fields:
168
334
  self.manual_overrides.append(field_name)
169
- logger.debug(f"Marked field '{field_name}' as manually overridden for game {self.id}")
335
+ logger.debug(
336
+ f"Marked field '{field_name}' as manually overridden for game {self.id}"
337
+ )
170
338
 
171
339
  def update_all_fields_manual(
172
340
  self,
@@ -174,66 +342,72 @@ class GamesTable(SQLiteDBTable):
174
342
  title_original: Optional[str] = None,
175
343
  title_romaji: Optional[str] = None,
176
344
  title_english: Optional[str] = None,
177
- type: Optional[str] = None,
345
+ game_type: Optional[str] = None,
178
346
  description: Optional[str] = None,
179
347
  image: Optional[str] = None,
180
348
  character_count: Optional[int] = None,
181
349
  difficulty: Optional[int] = None,
182
350
  links: Optional[List[Dict]] = None,
183
- completed: Optional[bool] = None
351
+ completed: Optional[bool] = None,
352
+ release_date: Optional[str] = None,
184
353
  ):
185
354
  """
186
355
  Update all fields of the game at once. Only provided fields will be updated.
187
356
  Fields that are updated will be automatically marked as manually overridden.
188
-
357
+
189
358
  Args:
190
359
  deck_id: jiten.moe deck ID
191
360
  title_original: Original Japanese title
192
361
  title_romaji: Romanized title
193
362
  title_english: English translated title
363
+ game_type: Game type (string)
194
364
  description: Game description
195
365
  image: Base64-encoded image data
196
366
  character_count: Total character count
197
367
  difficulty: Difficulty rating
198
368
  links: List of link objects
199
369
  completed: Whether the game is completed
370
+ release_date: Release date (ISO format string)
200
371
  """
201
372
  if deck_id is not None:
202
373
  self.deck_id = deck_id
203
- self.mark_field_manual('deck_id')
374
+ self.mark_field_manual("deck_id")
204
375
  if title_original is not None:
205
376
  self.title_original = title_original
206
- self.mark_field_manual('title_original')
377
+ self.mark_field_manual("title_original")
207
378
  if title_romaji is not None:
208
379
  self.title_romaji = title_romaji
209
- self.mark_field_manual('title_romaji')
380
+ self.mark_field_manual("title_romaji")
210
381
  if title_english is not None:
211
382
  self.title_english = title_english
212
- self.mark_field_manual('title_english')
213
- if type is not None:
214
- self.type = type
215
- self.mark_field_manual('type')
383
+ self.mark_field_manual("title_english")
384
+ if game_type is not None:
385
+ self.type = game_type
386
+ self.mark_field_manual("type")
216
387
  if description is not None:
217
388
  self.description = description
218
- self.mark_field_manual('description')
389
+ self.mark_field_manual("description")
219
390
  if image is not None:
220
391
  self.image = image
221
- self.mark_field_manual('image')
392
+ self.mark_field_manual("image")
222
393
  if character_count is not None:
223
394
  self.character_count = character_count
224
- self.mark_field_manual('character_count')
395
+ self.mark_field_manual("character_count")
225
396
  if difficulty is not None:
226
397
  self.difficulty = difficulty
227
- self.mark_field_manual('difficulty')
398
+ self.mark_field_manual("difficulty")
228
399
  if links is not None:
229
400
  self.links = links
230
- self.mark_field_manual('links')
401
+ self.mark_field_manual("links")
231
402
  if completed is not None:
232
403
  self.completed = completed
233
- self.mark_field_manual('completed')
234
-
404
+ self.mark_field_manual("completed")
405
+ if release_date is not None:
406
+ self.release_date = release_date
407
+ self.mark_field_manual("release_date")
408
+
235
409
  self.save()
236
- logger.info(f"Updated game {self.id} ({self.title_original})")
410
+ logger.debug(f"Updated game {self.id} ({self.title_original})")
237
411
 
238
412
  def update_all_fields_from_jiten(
239
413
  self,
@@ -241,28 +415,31 @@ class GamesTable(SQLiteDBTable):
241
415
  title_original: Optional[str] = None,
242
416
  title_romaji: Optional[str] = None,
243
417
  title_english: Optional[str] = None,
244
- type: Optional[str] = None,
418
+ game_type: Optional[str] = None,
245
419
  description: Optional[str] = None,
246
420
  image: Optional[str] = None,
247
421
  character_count: Optional[int] = None,
248
422
  difficulty: Optional[int] = None,
249
423
  links: Optional[List[Dict]] = None,
250
- completed: Optional[bool] = None
424
+ completed: Optional[bool] = None,
425
+ release_date: Optional[str] = None,
251
426
  ):
252
427
  """
253
428
  Update all fields of the game at once. Only provided fields will be updated.
254
-
429
+
255
430
  Args:
256
431
  deck_id: jiten.moe deck ID
257
432
  title_original: Original Japanese title
258
433
  title_romaji: Romanized title
259
434
  title_english: English translated title
435
+ game_type: Game type (string)
260
436
  description: Game description
261
437
  image: Base64-encoded image data
262
438
  character_count: Total character count
263
439
  difficulty: Difficulty rating
264
440
  links: List of link objects
265
441
  completed: Whether the game is completed
442
+ release_date: Release date (ISO format string)
266
443
  """
267
444
  if deck_id is not None:
268
445
  self.deck_id = deck_id
@@ -272,8 +449,8 @@ class GamesTable(SQLiteDBTable):
272
449
  self.title_romaji = title_romaji
273
450
  if title_english is not None:
274
451
  self.title_english = title_english
275
- if type is not None:
276
- self.type = type
452
+ if game_type is not None:
453
+ self.type = game_type
277
454
  if description is not None:
278
455
  self.description = description
279
456
  if image is not None:
@@ -285,36 +462,106 @@ class GamesTable(SQLiteDBTable):
285
462
  if links is not None:
286
463
  self.links = links
287
464
  if completed is not None:
288
- self.completed = completed
465
+ self.completed = completed
466
+ if release_date is not None:
467
+ logger.debug(
468
+ f"📅 GamesTable.update_all_fields_from_jiten: Setting release_date for game {self.id} to '{release_date}' (type: {type(release_date)})"
469
+ )
470
+ self.release_date = release_date
471
+ else:
472
+ logger.debug(
473
+ f"⏭️ GamesTable.update_all_fields_from_jiten: release_date is None for game {self.id}"
474
+ )
289
475
  self.save()
290
- logger.info(f"Updated game {self.id} ({self.title_original})")
476
+ logger.debug(
477
+ f"Updated game {self.id} ({self.title_original}) - final release_date: '{self.release_date}'"
478
+ )
291
479
 
292
480
  def add_link(self, link_type: int, url: str, link_id: Optional[int] = None):
293
481
  """
294
482
  Add a link to the game's links array and persist to database.
295
-
483
+
296
484
  Args:
297
485
  link_type: Type of link (e.g., 4 for AniList, 5 for MyAnimeList)
298
486
  url: URL of the link
299
487
  link_id: Optional link ID
300
-
488
+
301
489
  Note:
302
490
  Changes are automatically saved to the database.
303
491
  """
304
- new_link = {
305
- 'linkType': link_type,
306
- 'url': url,
307
- 'deckId': self.deck_id
308
- }
492
+ new_link = {"linkType": link_type, "url": url, "deckId": self.deck_id}
309
493
  if link_id is not None:
310
- new_link['linkId'] = link_id
311
-
494
+ new_link["linkId"] = link_id
495
+
312
496
  self.links.append(new_link)
313
497
  self.save()
314
498
 
315
499
  def get_lines(self) -> List:
316
500
  """Get all lines associated with this game."""
317
501
  from GameSentenceMiner.util.db import GameLinesTable
502
+
318
503
  rows = GameLinesTable._db.fetchall(
319
- f"SELECT * FROM {GameLinesTable._table} WHERE game_id=?", (self.id,))
320
- return [GameLinesTable.from_row(row) for row in rows]
504
+ f"SELECT * FROM {GameLinesTable._table} WHERE game_id=?", (self.id,)
505
+ )
506
+ return [GameLinesTable.from_row(row) for row in rows]
507
+
508
+ @classmethod
509
+ def get_by_game_line(cls, game_line) -> Optional["GamesTable"]:
510
+ """
511
+ Get game metadata from a game_line record using the game_id relationship.
512
+ Falls back to name-based lookup if game_id is missing or invalid.
513
+
514
+ Args:
515
+ game_line: A GameLinesTable record
516
+
517
+ Returns:
518
+ GamesTable: The game record, or None if not found
519
+ """
520
+ logger.debug(
521
+ f"[GET_BY_GAME_LINE] Looking up game for line with game_name='{game_line.game_name if hasattr(game_line, 'game_name') else 'N/A'}', game_id='{game_line.game_id if hasattr(game_line, 'game_id') else 'N/A'}'"
522
+ )
523
+
524
+ # First try using game_id relationship if it exists
525
+ if (
526
+ hasattr(game_line, "game_id")
527
+ and game_line.game_id
528
+ and game_line.game_id.strip()
529
+ ):
530
+ logger.debug(
531
+ f"[GET_BY_GAME_LINE] Attempting lookup by game_id: '{game_line.game_id}'"
532
+ )
533
+ game = cls.get(game_line.game_id)
534
+ if game:
535
+ logger.debug(
536
+ f"[GET_BY_GAME_LINE] ✓ Found game by game_id: title_original='{game.title_original}', deck_id={game.deck_id}, has_image={bool(game.image)}"
537
+ )
538
+ return game
539
+ else:
540
+ logger.warning(
541
+ f"[GET_BY_GAME_LINE] game_id '{game_line.game_id}' not found in games table, falling back to name lookup"
542
+ )
543
+ else:
544
+ logger.debug(
545
+ f"[GET_BY_GAME_LINE] No valid game_id, falling back to name lookup"
546
+ )
547
+
548
+ # Fallback to name-based lookup
549
+ if hasattr(game_line, "game_name") and game_line.game_name:
550
+ logger.debug(
551
+ f"[GET_BY_GAME_LINE] Attempting lookup by game_name: '{game_line.game_name}'"
552
+ )
553
+ game = cls.get_by_title(game_line.game_name)
554
+ if game:
555
+ logger.debug(
556
+ f"[GET_BY_GAME_LINE] ✓ Found game by name: title_original='{game.title_original}', deck_id={game.deck_id}, has_image={bool(game.image)}"
557
+ )
558
+ else:
559
+ logger.debug(
560
+ f"[GET_BY_GAME_LINE] ✗ No game found by name: '{game_line.game_name}'"
561
+ )
562
+ return game
563
+
564
+ logger.warning(
565
+ f"[GET_BY_GAME_LINE] ✗ No game found for line (no game_id or game_name)"
566
+ )
567
+ return None