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
@@ -0,0 +1,397 @@
1
+ """
2
+ Jiten.moe Update Script for GameSentenceMiner
3
+
4
+ This module provides functions to automatically update game metadata from jiten.moe,
5
+ respecting manual overrides set by users.
6
+
7
+ Usage:
8
+ from GameSentenceMiner.util.cron.jiten_update import update_all_jiten_games
9
+
10
+ # Update all linked games
11
+ result = update_all_jiten_games()
12
+ print(f"Updated {result['updated_games']} out of {result['total_games']} games")
13
+ """
14
+
15
+ import time
16
+ from typing import Optional, Dict, List
17
+
18
+ from GameSentenceMiner.util.games_table import GamesTable
19
+ from GameSentenceMiner.util.configuration import logger
20
+ from GameSentenceMiner.util.jiten_api_client import JitenApiClient
21
+
22
+
23
+ def fetch_jiten_data_for_game(game: GamesTable) -> Optional[Dict]:
24
+ """
25
+ Fetch fresh data from jiten.moe API for a specific game.
26
+
27
+ Args:
28
+ game: GamesTable object with a deck_id
29
+
30
+ Returns:
31
+ Dictionary with normalized jiten.moe data (snake_case keys), or None if fetch fails
32
+
33
+ Example return structure:
34
+ {
35
+ 'deck_id': 123,
36
+ 'title_original': '君と彼女と彼女の恋。',
37
+ 'title_romaji': 'Kimi to Kanojo to Kanojo no Koi.',
38
+ 'title_english': 'You, Me, and Her',
39
+ 'description': 'A visual novel about...',
40
+ 'cover_name': 'https://...',
41
+ 'media_type': 7, # 1=Anime, 7=Visual Novel, 2=Manga
42
+ 'character_count': 50000,
43
+ 'difficulty': 5,
44
+ 'difficulty_raw': 5.2,
45
+ 'links': [...],
46
+ 'aliases': [...],
47
+ 'release_date': '2013-06-28'
48
+ }
49
+ """
50
+ if not game.deck_id:
51
+ logger.debug(
52
+ f"Game {game.id} ({game.title_original}) has no deck_id, skipping jiten fetch"
53
+ )
54
+ return None
55
+
56
+ try:
57
+ logger.debug(
58
+ f"Fetching jiten.moe data for game: {game.title_original} (deck_id: {game.deck_id})"
59
+ )
60
+
61
+ # Use direct deck detail API endpoint
62
+ data = JitenApiClient.get_deck_detail(game.deck_id)
63
+
64
+ if not data:
65
+ logger.debug(f"Failed to fetch deck detail for deck_id {game.deck_id}")
66
+ return None
67
+
68
+ # Extract main deck data from the detail response
69
+ main_deck = data.get("data", {}).get("mainDeck")
70
+ if not main_deck:
71
+ logger.debug(f"No mainDeck found in response for deck_id {game.deck_id}")
72
+ return None
73
+
74
+ # Normalize the deck data
75
+ jiten_data = JitenApiClient.normalize_deck_data(main_deck)
76
+ logger.debug(
77
+ f"Successfully fetched jiten.moe data for: {jiten_data['title_original']}"
78
+ )
79
+ return jiten_data
80
+
81
+ except Exception as e:
82
+ logger.debug(f"Unexpected error fetching jiten data for game {game.id}: {e}")
83
+ return None
84
+
85
+
86
+ def update_single_game_from_jiten(game: GamesTable, jiten_data: Dict) -> Dict:
87
+ """
88
+ Update a single game's fields from jiten.moe data, respecting manual overrides.
89
+ Always re-downloads cover images.
90
+
91
+ Args:
92
+ game: GamesTable object to update
93
+ jiten_data: Dictionary with jiten.moe data (from fetch_jiten_data_for_game)
94
+
95
+ Returns:
96
+ Dictionary with update summary:
97
+ {
98
+ 'success': bool,
99
+ 'updated_fields': List[str],
100
+ 'skipped_fields': List[str],
101
+ 'error': Optional[str]
102
+ }
103
+ """
104
+ try:
105
+ update_fields = {}
106
+ skipped_fields = []
107
+
108
+ # Ensure manual_overrides is always a list
109
+ manual_overrides = (
110
+ game.manual_overrides if game.manual_overrides is not None else []
111
+ )
112
+ if not isinstance(manual_overrides, list):
113
+ logger.warning(
114
+ f"⚠️ manual_overrides is not a list for game {game.id}: {type(manual_overrides)}"
115
+ )
116
+ manual_overrides = []
117
+
118
+ logger.debug(
119
+ f"Checking fields for game {game.id} (manual overrides: {manual_overrides})"
120
+ )
121
+
122
+ # Check each field against manual overrides
123
+ if "deck_id" not in manual_overrides:
124
+ update_fields["deck_id"] = jiten_data["deck_id"]
125
+ else:
126
+ skipped_fields.append("deck_id")
127
+
128
+ if "title_original" not in manual_overrides and jiten_data.get(
129
+ "title_original"
130
+ ):
131
+ update_fields["title_original"] = jiten_data["title_original"]
132
+ elif "title_original" in manual_overrides:
133
+ skipped_fields.append("title_original")
134
+
135
+ if "title_romaji" not in manual_overrides and jiten_data.get("title_romaji"):
136
+ update_fields["title_romaji"] = jiten_data["title_romaji"]
137
+ elif "title_romaji" in manual_overrides:
138
+ skipped_fields.append("title_romaji")
139
+
140
+ if "title_english" not in manual_overrides and jiten_data.get("title_english"):
141
+ update_fields["title_english"] = jiten_data["title_english"]
142
+ elif "title_english" in manual_overrides:
143
+ skipped_fields.append("title_english")
144
+
145
+ if "type" not in manual_overrides and jiten_data.get("media_type"):
146
+ # Map media type to string
147
+ media_type_map = {1: "Anime", 7: "Visual Novel", 2: "Manga"}
148
+ update_fields["game_type"] = media_type_map.get(
149
+ jiten_data["media_type"], "Unknown"
150
+ )
151
+ elif "type" in manual_overrides:
152
+ skipped_fields.append("type")
153
+
154
+ if "description" not in manual_overrides and jiten_data.get("description"):
155
+ update_fields["description"] = jiten_data["description"]
156
+ elif "description" in manual_overrides:
157
+ skipped_fields.append("description")
158
+
159
+ if (
160
+ "difficulty" not in manual_overrides
161
+ and jiten_data.get("difficulty") is not None
162
+ ):
163
+ update_fields["difficulty"] = jiten_data["difficulty"]
164
+ elif "difficulty" in manual_overrides:
165
+ skipped_fields.append("difficulty")
166
+
167
+ if (
168
+ "character_count" not in manual_overrides
169
+ and jiten_data.get("character_count") is not None
170
+ ):
171
+ update_fields["character_count"] = jiten_data["character_count"]
172
+ elif "character_count" in manual_overrides:
173
+ skipped_fields.append("character_count")
174
+
175
+ if "links" not in manual_overrides and jiten_data.get("links"):
176
+ update_fields["links"] = jiten_data["links"]
177
+ elif "links" in manual_overrides:
178
+ skipped_fields.append("links")
179
+
180
+ if "release_date" not in manual_overrides and jiten_data.get("release_date"):
181
+ update_fields["release_date"] = jiten_data["release_date"]
182
+ elif "release_date" in manual_overrides:
183
+ skipped_fields.append("release_date")
184
+
185
+ # Always re-download image if not manually overridden
186
+ if "image" not in manual_overrides and jiten_data.get("cover_name"):
187
+ image_data = JitenApiClient.download_cover_image(jiten_data["cover_name"])
188
+ if image_data:
189
+ update_fields["image"] = image_data
190
+ logger.debug(f"Downloaded and encoded image for game {game.id}")
191
+ else:
192
+ logger.debug(f"Failed to download image for game {game.id}")
193
+ elif "image" in manual_overrides:
194
+ skipped_fields.append("image")
195
+
196
+ # Update the game using the jiten update method (doesn't mark as manual)
197
+ if update_fields:
198
+ game.update_all_fields_from_jiten(**update_fields)
199
+ logger.debug(
200
+ f"Updated game {game.id} ({game.title_original}): {len(update_fields)} fields"
201
+ )
202
+ return {
203
+ "success": True,
204
+ "updated_fields": list(update_fields.keys()),
205
+ "skipped_fields": skipped_fields,
206
+ "error": None,
207
+ }
208
+ else:
209
+ logger.debug(
210
+ f"No fields updated for game {game.id} - all fields are manually overridden"
211
+ )
212
+ return {
213
+ "success": True,
214
+ "updated_fields": [],
215
+ "skipped_fields": skipped_fields,
216
+ "error": None,
217
+ }
218
+
219
+ except Exception as e:
220
+ logger.error(
221
+ f"💥 Error updating game {game.id} from jiten data: {e}", exc_info=True
222
+ )
223
+ return {
224
+ "success": False,
225
+ "updated_fields": [],
226
+ "skipped_fields": skipped_fields,
227
+ "error": str(e),
228
+ }
229
+
230
+
231
+ def update_all_jiten_games() -> Dict:
232
+ """
233
+ Update all games that are linked to jiten.moe (have a deck_id).
234
+
235
+ This is the main entry point for the jiten update cron job.
236
+ - Continues on errors (individual game failures don't stop the process)
237
+ - Always re-downloads images
238
+ - Adds 1 second delay between games to avoid rate limiting
239
+
240
+ Returns:
241
+ Dictionary with summary statistics:
242
+ {
243
+ 'total_games': int, # Total games in database
244
+ 'linked_games': int, # Games with deck_id
245
+ 'updated_games': int, # Successfully updated
246
+ 'failed_games': int, # Failed to update
247
+ 'skipped_games': int, # No deck_id
248
+ 'total_fields_updated': int, # Total fields updated across all games
249
+ 'details': List[Dict] # Per-game details
250
+ }
251
+ """
252
+ logger.debug("Starting jiten.moe update for all linked games")
253
+
254
+ start_time = time.time()
255
+
256
+ # Get all games
257
+ all_games = GamesTable.all()
258
+ total_games = len(all_games)
259
+
260
+ # Filter for linked games (have deck_id)
261
+ linked_games = [game for game in all_games if game.deck_id]
262
+ linked_count = len(linked_games)
263
+ skipped_count = total_games - linked_count
264
+
265
+ logger.debug(
266
+ f"Found {total_games} total games, {linked_count} linked to jiten.moe, {skipped_count} unlinked"
267
+ )
268
+
269
+ if linked_count == 0:
270
+ logger.debug("No linked games found, nothing to update")
271
+ return {
272
+ "total_games": total_games,
273
+ "linked_games": 0,
274
+ "updated_games": 0,
275
+ "failed_games": 0,
276
+ "skipped_games": skipped_count,
277
+ "total_fields_updated": 0,
278
+ "details": [],
279
+ }
280
+
281
+ # Process each linked game
282
+ updated_count = 0
283
+ failed_count = 0
284
+ total_fields_updated = 0
285
+ details = []
286
+
287
+ for i, game in enumerate(linked_games, 1):
288
+ logger.debug(
289
+ f"Processing game {i}/{linked_count}: {game.title_original} (deck_id: {game.deck_id})"
290
+ )
291
+
292
+ game_detail = {
293
+ "game_id": game.id,
294
+ "title": game.title_original,
295
+ "deck_id": game.deck_id,
296
+ "success": False,
297
+ "updated_fields": [],
298
+ "skipped_fields": [],
299
+ "error": None,
300
+ }
301
+
302
+ try:
303
+ # Fetch jiten data
304
+ jiten_data = fetch_jiten_data_for_game(game)
305
+
306
+ if jiten_data is None:
307
+ logger.debug(f"Failed to fetch jiten data for game {game.id}, skipping")
308
+ failed_count += 1
309
+ game_detail["error"] = "Failed to fetch jiten data"
310
+ details.append(game_detail)
311
+ continue
312
+
313
+ # Update the game
314
+ result = update_single_game_from_jiten(game, jiten_data)
315
+
316
+ if result["success"]:
317
+ updated_count += 1
318
+ total_fields_updated += len(result["updated_fields"])
319
+ game_detail["success"] = True
320
+ game_detail["updated_fields"] = result["updated_fields"]
321
+ game_detail["skipped_fields"] = result["skipped_fields"]
322
+ else:
323
+ failed_count += 1
324
+ game_detail["error"] = result["error"]
325
+
326
+ details.append(game_detail)
327
+
328
+ except Exception as e:
329
+ logger.error(
330
+ f"💥 Unexpected error processing game {game.id}: {e}", exc_info=True
331
+ )
332
+ failed_count += 1
333
+ game_detail["error"] = str(e)
334
+ details.append(game_detail)
335
+
336
+ # Add 1 second delay between games (except after the last one)
337
+ if i < linked_count:
338
+ logger.debug(f"Waiting 1 second before next game...")
339
+ time.sleep(1)
340
+
341
+ elapsed_time = time.time() - start_time
342
+
343
+ # Log summary
344
+ logger.debug("Jiten.moe update completed")
345
+ logger.debug(f"Summary:")
346
+ logger.debug(f" - Total games: {total_games}")
347
+ logger.debug(f" - Linked games: {linked_count}")
348
+ logger.debug(f" - Successfully updated: {updated_count}")
349
+ logger.debug(f" - Failed: {failed_count}")
350
+ logger.debug(f" - Skipped (no deck_id): {skipped_count}")
351
+ logger.debug(f" - Total fields updated: {total_fields_updated}")
352
+ logger.debug(f" - Time elapsed: {elapsed_time:.2f} seconds")
353
+
354
+ return {
355
+ "total_games": total_games,
356
+ "linked_games": linked_count,
357
+ "updated_games": updated_count,
358
+ "failed_games": failed_count,
359
+ "skipped_games": skipped_count,
360
+ "total_fields_updated": total_fields_updated,
361
+ "elapsed_time": elapsed_time,
362
+ "details": details,
363
+ }
364
+
365
+
366
+ # Example usage for testing
367
+ if __name__ == "__main__":
368
+ # Run the update
369
+ result = update_all_jiten_games()
370
+
371
+ # Print summary
372
+ print("\n" + "=" * 80)
373
+ print("JITEN UPDATE SUMMARY")
374
+ print("=" * 80)
375
+ print(f"Total games: {result['total_games']}")
376
+ print(f"Linked games: {result['linked_games']}")
377
+ print(f"Successfully updated: {result['updated_games']}")
378
+ print(f"Failed: {result['failed_games']}")
379
+ print(f"Skipped (no deck_id): {result['skipped_games']}")
380
+ print(f"Total fields updated: {result['total_fields_updated']}")
381
+ print(f"Time elapsed: {result['elapsed_time']:.2f} seconds")
382
+ print("=" * 80)
383
+
384
+ # Print per-game details
385
+ if result["details"]:
386
+ print("\nPER-GAME DETAILS:")
387
+ print("-" * 80)
388
+ for detail in result["details"]:
389
+ status = "✅" if detail["success"] else "❌"
390
+ print(f"{status} {detail['title']} (deck_id: {detail['deck_id']})")
391
+ if detail["updated_fields"]:
392
+ print(f" Updated: {', '.join(detail['updated_fields'])}")
393
+ if detail["skipped_fields"]:
394
+ print(f" Skipped: {', '.join(detail['skipped_fields'])}")
395
+ if detail["error"]:
396
+ print(f" Error: {detail['error']}")
397
+ print("-" * 80)
@@ -0,0 +1,154 @@
1
+ """
2
+ One-time Game Population Cron Job for GameSentenceMiner
3
+
4
+ This module provides a one-time cron job that auto-creates game records from game_lines.
5
+ This ensures the games table is populated before the daily rollup runs, so that
6
+ game_activity_data and games_played_ids can be properly populated in the rollup.
7
+
8
+ This job should run once and then mark itself as complete.
9
+
10
+ Usage:
11
+ from GameSentenceMiner.util.cron.populate_games import populate_games_table
12
+
13
+ # Run the one-time population
14
+ result = populate_games_table()
15
+ print(f"Created {result['created']} games, linked {result['linked_lines']} lines")
16
+ """
17
+
18
+ from GameSentenceMiner.util.db import GameLinesTable
19
+ from GameSentenceMiner.util.games_table import GamesTable
20
+ from GameSentenceMiner.util.configuration import logger
21
+
22
+
23
+ def populate_games_table():
24
+ """
25
+ Auto-create game records for any game_lines that don't have corresponding games.
26
+ This is a one-time operation that ensures the games table is populated.
27
+
28
+ Returns:
29
+ Dictionary with execution summary:
30
+ {
31
+ 'success': bool,
32
+ 'created': int, # Number of new games created
33
+ 'linked_lines': int, # Number of game_lines linked to games
34
+ 'errors': int,
35
+ 'error_message': str or None
36
+ }
37
+ """
38
+ logger.info("Starting one-time game population from game_lines")
39
+
40
+ created_count = 0
41
+ linked_lines_count = 0
42
+ errors = 0
43
+
44
+ try:
45
+ # Get all distinct game names from game_lines
46
+ game_names_from_lines = GameLinesTable._db.fetchall(
47
+ f"SELECT DISTINCT game_name FROM {GameLinesTable._table} "
48
+ f"WHERE game_name IS NOT NULL AND game_name != ''"
49
+ )
50
+
51
+ if not game_names_from_lines:
52
+ logger.info("No game names found in game_lines table")
53
+ return {
54
+ 'success': True,
55
+ 'created': 0,
56
+ 'linked_lines': 0,
57
+ 'errors': 0,
58
+ 'error_message': None
59
+ }
60
+
61
+ logger.info(f"Found {len(game_names_from_lines)} distinct game names in game_lines")
62
+
63
+ # Get existing game titles to avoid duplicates
64
+ existing_games_rows = GamesTable._db.fetchall(
65
+ f"SELECT title_original FROM {GamesTable._table}"
66
+ )
67
+ existing_titles = {row[0] for row in existing_games_rows}
68
+
69
+ logger.info(f"Found {len(existing_titles)} existing games in games table")
70
+
71
+ # Auto-create games for game_lines that don't have corresponding games
72
+ for row in game_names_from_lines:
73
+ game_name = row[0]
74
+
75
+ # Skip if game already exists
76
+ if game_name in existing_titles:
77
+ logger.debug(f"Game '{game_name}' already exists, skipping")
78
+ continue
79
+
80
+ try:
81
+ # Use get_or_create_by_name which checks for existing mappings
82
+ # and reuses them instead of creating duplicates
83
+ game = GamesTable.get_or_create_by_name(game_name)
84
+
85
+ # Link any orphaned game_lines to this game
86
+ GameLinesTable._db.execute(
87
+ f"UPDATE {GameLinesTable._table} SET game_id = ? "
88
+ f"WHERE game_name = ? AND (game_id IS NULL OR game_id = '')",
89
+ (game.id, game_name),
90
+ commit=True,
91
+ )
92
+
93
+ # Count how many lines were linked
94
+ linked_count = GameLinesTable._db.fetchone(
95
+ f"SELECT COUNT(*) FROM {GameLinesTable._table} WHERE game_id = ?",
96
+ (game.id,),
97
+ )
98
+ lines_linked = linked_count[0] if linked_count else 0
99
+
100
+ logger.info(
101
+ f"Created game '{game_name}' (id={game.id}) and linked {lines_linked} lines"
102
+ )
103
+
104
+ created_count += 1
105
+ linked_lines_count += lines_linked
106
+ existing_titles.add(game_name)
107
+
108
+ except Exception as e:
109
+ logger.error(f"Error creating game for '{game_name}': {e}", exc_info=True)
110
+ errors += 1
111
+ continue
112
+
113
+ logger.info(
114
+ f"Game population completed: created {created_count} games, "
115
+ f"linked {linked_lines_count} lines, {errors} errors"
116
+ )
117
+
118
+ return {
119
+ 'success': True,
120
+ 'created': created_count,
121
+ 'linked_lines': linked_lines_count,
122
+ 'errors': errors,
123
+ 'error_message': None
124
+ }
125
+
126
+ except Exception as e:
127
+ error_msg = str(e)
128
+ logger.error(f"Fatal error in populate_games_table: {error_msg}", exc_info=True)
129
+
130
+ return {
131
+ 'success': False,
132
+ 'created': created_count,
133
+ 'linked_lines': linked_lines_count,
134
+ 'errors': errors + 1,
135
+ 'error_message': error_msg
136
+ }
137
+
138
+
139
+ # Example usage for testing
140
+ if __name__ == '__main__':
141
+ # Run the one-time population
142
+ result = populate_games_table()
143
+
144
+ # Print summary
145
+ print("\n" + "=" * 80)
146
+ print("GAME POPULATION SUMMARY")
147
+ print("=" * 80)
148
+ print(f"Success: {'Yes' if result['success'] else 'No'}")
149
+ print(f"Games created: {result['created']}")
150
+ print(f"Lines linked: {result['linked_lines']}")
151
+ print(f"Errors: {result['errors']}")
152
+ if result['error_message']:
153
+ print(f"Error: {result['error_message']}")
154
+ print("=" * 80)
@@ -0,0 +1,148 @@
1
+ """
2
+ Cron Job Runner for GameSentenceMiner
3
+
4
+ This script checks for due cron jobs and executes them.
5
+ Should be called periodically (e.g., every hour) by an external scheduler.
6
+
7
+ Usage:
8
+ python -m GameSentenceMiner.util.cron.run_crons
9
+ """
10
+
11
+ from GameSentenceMiner.util.cron_table import CronTable
12
+ from GameSentenceMiner.util.configuration import logger
13
+
14
+
15
+ def run_due_crons():
16
+ """
17
+ Check for and execute all due cron jobs.
18
+
19
+ Returns:
20
+ Dictionary with execution summary
21
+ """
22
+ logger.info("Checking for due cron jobs...")
23
+
24
+ # Get all cron jobs that need to run
25
+ due_crons = CronTable.get_due_crons()
26
+
27
+ if not due_crons:
28
+ logger.info("No cron jobs are due to run at this time")
29
+ return {
30
+ 'total_checked': 0,
31
+ 'executed': 0,
32
+ 'failed': 0,
33
+ 'details': []
34
+ }
35
+
36
+ logger.info(f"📋 Found {len(due_crons)} cron job(s) due to run")
37
+
38
+ executed_count = 0
39
+ failed_count = 0
40
+ details = []
41
+
42
+ for cron in due_crons:
43
+ logger.info(f"Executing cron job: {cron.name}")
44
+ logger.info(f"Description: {cron.description}")
45
+
46
+ detail = {
47
+ 'name': cron.name,
48
+ 'description': cron.description,
49
+ 'success': False,
50
+ 'error': None
51
+ }
52
+
53
+ try:
54
+ # Execute populate_games BEFORE daily_stats_rollup to ensure games table is populated
55
+ if cron.name == 'populate_games':
56
+ from GameSentenceMiner.util.cron.populate_games import populate_games_table
57
+ result = populate_games_table()
58
+
59
+ # Mark as successfully run (even if there were some errors, as long as it completed)
60
+ CronTable.just_ran(cron.id)
61
+ executed_count += 1
62
+ detail['success'] = True
63
+ detail['result'] = result
64
+
65
+ logger.info(f"Successfully executed {cron.name}")
66
+ logger.info(f"Created: {result['created']} games, Linked: {result['linked_lines']} lines, Errors: {result['errors']}")
67
+
68
+ # Execute the appropriate function based on cron name
69
+ elif cron.name == 'jiten_sync':
70
+ from GameSentenceMiner.util.cron.jiten_update import update_all_jiten_games
71
+ result = update_all_jiten_games()
72
+
73
+ # Mark as successfully run
74
+ CronTable.just_ran(cron.id)
75
+ executed_count += 1
76
+ detail['success'] = True
77
+ detail['result'] = result
78
+
79
+ logger.info(f"Successfully executed {cron.name}")
80
+ logger.info(f"Updated: {result['updated_games']}/{result['linked_games']} games")
81
+
82
+ elif cron.name == 'daily_stats_rollup':
83
+ from GameSentenceMiner.util.cron.daily_rollup import run_daily_rollup
84
+ result = run_daily_rollup()
85
+
86
+ # Mark as successfully run
87
+ CronTable.just_ran(cron.id)
88
+ executed_count += 1
89
+ detail['success'] = True
90
+ detail['result'] = result
91
+
92
+ logger.info(f"Successfully executed {cron.name}")
93
+ logger.info(f"Processed: {result['processed']} dates, Overwritten: {result['overwritten']}, Errors: {result['errors']}")
94
+
95
+ else:
96
+ logger.error(f"⚠️ Unknown cron job: {cron.name}")
97
+ detail['error'] = f"Unknown cron job: {cron.name}"
98
+ failed_count += 1
99
+
100
+ except Exception as e:
101
+ logger.error(f"Failed to execute {cron.name}: {e}", exc_info=True)
102
+ detail['error'] = str(e)
103
+ failed_count += 1
104
+
105
+ details.append(detail)
106
+ logger.info("Cron job check completed")
107
+ logger.info(f"Total checked: {len(due_crons)}")
108
+ logger.info(f"Successfully executed: {executed_count}")
109
+ logger.info(f"Failed: {failed_count}")
110
+
111
+ return {
112
+ 'total_checked': len(due_crons),
113
+ 'executed': executed_count,
114
+ 'failed': failed_count,
115
+ 'details': details
116
+ }
117
+
118
+
119
+ # for me to manually check
120
+ if __name__ == '__main__':
121
+ # Run the cron checker
122
+ result = run_due_crons()
123
+
124
+ # Print summary
125
+ print("\n" + "=" * 80)
126
+ print("CRON EXECUTION SUMMARY")
127
+ print("=" * 80)
128
+ print(f"Total cron jobs checked: {result['total_checked']}")
129
+ print(f"Successfully executed: {result['executed']}")
130
+ print(f"Failed: {result['failed']}")
131
+ print("=" * 80)
132
+
133
+ # Print details
134
+ if result['details']:
135
+ print("\nDETAILS:")
136
+ print("-" * 80)
137
+ for detail in result['details']:
138
+ status = "✅" if detail['success'] else "❌"
139
+ print(f"{status} {detail['name']}: {detail['description']}")
140
+ if detail.get('error'):
141
+ print(f" Error: {detail['error']}")
142
+ elif detail.get('result'):
143
+ res = detail['result']
144
+ if 'updated_games' in res:
145
+ print(f" Updated {res['updated_games']}/{res['linked_games']} games")
146
+ elif 'processed' in res:
147
+ print(f" Processed {res['processed']} dates, Overwritten {res['overwritten']}, Errors {res['errors']}")
148
+ print("-" * 80)