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,1015 @@
1
+ """
2
+ Jiten.moe Database API Routes
3
+
4
+ This module contains all API routes related to jiten.moe integration and game management.
5
+ Handles game linking, searching, updating, and management operations.
6
+ """
7
+
8
+ import json
9
+ from flask import request, jsonify
10
+
11
+ from GameSentenceMiner.util.db import GameLinesTable
12
+ from GameSentenceMiner.util.configuration import logger
13
+ from GameSentenceMiner.util.jiten_api_client import JitenApiClient
14
+ from GameSentenceMiner.util.cron.daily_rollup import run_daily_rollup
15
+
16
+
17
+ def add_jiten_link_to_game(game, deck_id):
18
+ """
19
+ Helper function to add or update Jiten.moe link in game's links list.
20
+ Ensures there's only one Jiten link and it's up to date.
21
+ """
22
+ jiten_url = f"https://jiten.moe/decks/media/{deck_id}/detail"
23
+
24
+ # Ensure game.links is a list (handle cases where it might be a string or None)
25
+ if not isinstance(game.links, list):
26
+ if isinstance(game.links, str):
27
+ try:
28
+ game.links = json.loads(game.links)
29
+ except (json.JSONDecodeError, TypeError):
30
+ game.links = []
31
+ else:
32
+ game.links = []
33
+
34
+ # Check if a Jiten link already exists
35
+ jiten_link_index = None
36
+ for i, link in enumerate(game.links):
37
+ # Handle both string and object formats for backward compatibility
38
+ link_url = link if isinstance(link, str) else (link.get("url") if isinstance(link, dict) else "")
39
+ if "jiten.moe/deck" in link_url:
40
+ jiten_link_index = i
41
+ break
42
+
43
+ # Create Jiten link object with proper structure
44
+ jiten_link = {
45
+ "url": jiten_url,
46
+ "linkType": 99, # Jiten.moe link type
47
+ "deckId": deck_id
48
+ }
49
+
50
+ if jiten_link_index is not None:
51
+ # Update existing Jiten link
52
+ game.links[jiten_link_index] = jiten_link
53
+ logger.debug(f"Updated existing Jiten link to: {jiten_url}")
54
+ else:
55
+ # Add new Jiten link
56
+ game.links.append(jiten_link)
57
+ logger.debug(f"Added new Jiten link: {jiten_url}")
58
+
59
+
60
+ def register_jiten_database_api_routes(app):
61
+ """Register all Jiten-related database API routes with the Flask app."""
62
+
63
+ @app.route("/api/games-management", methods=["GET"])
64
+ def api_games_management():
65
+ """
66
+ Get all games with their jiten.moe linking status and statistics.
67
+ Automatically creates game records for orphaned game_lines.
68
+ """
69
+ try:
70
+ from GameSentenceMiner.util.games_table import GamesTable
71
+
72
+ # First, auto-create games for any orphaned game_lines
73
+ # Get all distinct game names from game_lines
74
+ game_names_from_lines = GameLinesTable._db.fetchall(
75
+ f"SELECT DISTINCT game_name FROM {GameLinesTable._table} "
76
+ f"WHERE game_name IS NOT NULL AND game_name != ''"
77
+ )
78
+
79
+ # Get existing game titles
80
+ existing_games_rows = GamesTable._db.fetchall(
81
+ f"SELECT title_original FROM {GamesTable._table}"
82
+ )
83
+ existing_titles = {row[0] for row in existing_games_rows}
84
+
85
+ # Auto-create games for orphaned game_lines using get_or_create_by_name
86
+ # This will reuse existing game_id mappings instead of creating duplicates
87
+ for row in game_names_from_lines:
88
+ game_name = row[0]
89
+ if game_name not in existing_titles:
90
+ # Use get_or_create_by_name which checks for existing mappings
91
+ game = GamesTable.get_or_create_by_name(game_name)
92
+
93
+ # Link any orphaned game_lines to this game
94
+ GameLinesTable._db.execute(
95
+ f"UPDATE {GameLinesTable._table} SET game_id = ? WHERE game_name = ? AND (game_id IS NULL OR game_id = '')",
96
+ (game.id, game_name),
97
+ commit=True,
98
+ )
99
+
100
+ logger.debug(
101
+ f"Auto-linked game_lines for: {game_name} -> game_id={game.id}"
102
+ )
103
+ existing_titles.add(game_name)
104
+
105
+ # Get all games from the games table
106
+ all_games = GamesTable.all()
107
+
108
+ games_data = []
109
+ for game in all_games:
110
+ # Get line count and character count for this game
111
+ lines = game.get_lines()
112
+ line_count = len(lines)
113
+
114
+ # Calculate actual mined character count from lines (don't store it)
115
+ actual_char_count = sum(
116
+ len(line.line_text) if line.line_text else 0 for line in lines
117
+ )
118
+
119
+ # Determine linking status
120
+ is_linked = bool(game.deck_id)
121
+ has_manual_overrides = len(game.manual_overrides) > 0
122
+
123
+ # Get start and end dates
124
+ start_date = GamesTable.get_start_date(game.id)
125
+ last_played = GamesTable.get_last_played_date(game.id)
126
+
127
+ games_data.append(
128
+ {
129
+ "id": game.id,
130
+ "title_original": game.title_original,
131
+ "title_romaji": game.title_romaji,
132
+ "title_english": game.title_english,
133
+ "type": game.type,
134
+ "description": game.description,
135
+ "image": game.image,
136
+ "deck_id": game.deck_id,
137
+ "difficulty": game.difficulty,
138
+ "completed": game.completed,
139
+ "is_linked": is_linked,
140
+ "has_manual_overrides": has_manual_overrides,
141
+ "manual_overrides": game.manual_overrides,
142
+ "line_count": line_count,
143
+ "mined_character_count": actual_char_count, # Mined count (calculated from lines)
144
+ "jiten_character_count": game.character_count, # Jiten total (from jiten.moe)
145
+ "start_date": start_date,
146
+ "last_played": last_played,
147
+ "links": game.links,
148
+ "release_date": game.release_date, # Add release date to API response
149
+ "obs_scene_name": game.obs_scene_name
150
+ if hasattr(game, "obs_scene_name")
151
+ else "", # Add OBS scene name
152
+ }
153
+ )
154
+
155
+ # Sort by mined character count (most active games first)
156
+ games_data.sort(key=lambda x: x["mined_character_count"], reverse=True)
157
+
158
+ # Calculate summary statistics
159
+ total_games = len(games_data)
160
+ linked_games = sum(1 for game in games_data if game["is_linked"])
161
+ unlinked_games = total_games - linked_games
162
+
163
+ return jsonify(
164
+ {
165
+ "games": games_data,
166
+ "summary": {
167
+ "total_games": total_games,
168
+ "linked_games": linked_games,
169
+ "unlinked_games": unlinked_games,
170
+ },
171
+ }
172
+ ), 200
173
+
174
+ except Exception as e:
175
+ logger.error(f"Error fetching games management data: {e}", exc_info=True)
176
+ return jsonify({"error": "Failed to fetch games data"}), 500
177
+
178
+ @app.route("/api/jiten-search", methods=["GET"])
179
+ def api_jiten_search():
180
+ """
181
+ Search jiten.moe media decks by title.
182
+ """
183
+ try:
184
+ title_filter = request.args.get("title", "").strip()
185
+ if not title_filter:
186
+ return jsonify({"error": "Title parameter is required"}), 400
187
+
188
+ # Use centralized API client
189
+ data = JitenApiClient.search_media_decks(title_filter)
190
+
191
+ if not data:
192
+ return jsonify({"error": "Failed to search jiten.moe database"}), 500
193
+
194
+ # Process and format the results
195
+ results = []
196
+ for item in data.get("data", []):
197
+ # Use the normalize function for consistency
198
+ normalized_item = JitenApiClient.normalize_deck_data(item)
199
+ results.append(normalized_item)
200
+
201
+ return jsonify(
202
+ {"results": results, "total_items": data.get("totalItems", 0)}
203
+ ), 200
204
+
205
+ except Exception as e:
206
+ logger.debug(f"Error in jiten search: {e}")
207
+ return jsonify({"error": "Search failed"}), 500
208
+
209
+ @app.route("/api/games/<game_id>/link-jiten", methods=["POST"])
210
+ def api_link_game_to_jiten(game_id):
211
+ """
212
+ Link a game to jiten.moe data, respecting manual overrides.
213
+ """
214
+ try:
215
+ from GameSentenceMiner.util.games_table import GamesTable
216
+ import requests
217
+
218
+ data = request.get_json()
219
+ if not data:
220
+ return jsonify({"error": "No data provided"}), 400
221
+
222
+ deck_id = data.get("deck_id")
223
+ if not deck_id:
224
+ return jsonify({"error": "deck_id is required"}), 400
225
+
226
+ # Get the game
227
+ game = GamesTable.get(game_id)
228
+ if not game:
229
+ return jsonify({"error": "Game not found"}), 404
230
+
231
+ # Get jiten.moe data to ensure it's valid
232
+ jiten_data = data.get("jiten_data", {})
233
+
234
+ # Update game with jiten.moe data, respecting manual overrides
235
+ update_fields = {}
236
+
237
+ # Only update fields that are not manually overridden
238
+ if "deck_id" not in game.manual_overrides:
239
+ update_fields["deck_id"] = deck_id
240
+
241
+ if "title_original" not in game.manual_overrides and jiten_data.get(
242
+ "title_original"
243
+ ):
244
+ update_fields["title_original"] = jiten_data["title_original"]
245
+
246
+ if "title_romaji" not in game.manual_overrides and jiten_data.get(
247
+ "title_romaji"
248
+ ):
249
+ update_fields["title_romaji"] = jiten_data["title_romaji"]
250
+
251
+ if "title_english" not in game.manual_overrides and jiten_data.get(
252
+ "title_english"
253
+ ):
254
+ update_fields["title_english"] = jiten_data["title_english"]
255
+
256
+ if "type" not in game.manual_overrides and jiten_data.get("media_type_string"):
257
+ # Use the pre-converted media type string from jiten_api_client
258
+ update_fields["game_type"] = jiten_data["media_type_string"]
259
+
260
+ if "description" not in game.manual_overrides and jiten_data.get(
261
+ "description"
262
+ ):
263
+ update_fields["description"] = jiten_data["description"]
264
+
265
+ if (
266
+ "difficulty" not in game.manual_overrides
267
+ and jiten_data.get("difficulty") is not None
268
+ ):
269
+ update_fields["difficulty"] = jiten_data["difficulty"]
270
+
271
+ # Frontend sends snake_case (character_count) from the search endpoint
272
+ if (
273
+ "character_count" not in game.manual_overrides
274
+ and jiten_data.get("character_count") is not None
275
+ ):
276
+ update_fields["character_count"] = jiten_data["character_count"]
277
+
278
+ if "links" not in game.manual_overrides and jiten_data.get("links"):
279
+ update_fields["links"] = jiten_data["links"]
280
+
281
+ if "release_date" not in game.manual_overrides and jiten_data.get(
282
+ "release_date"
283
+ ):
284
+ update_fields["release_date"] = jiten_data["release_date"]
285
+
286
+ # Download and encode image if not manually overridden
287
+ if "image" not in game.manual_overrides and jiten_data.get("cover_name"):
288
+ image_data = JitenApiClient.download_cover_image(
289
+ jiten_data["cover_name"]
290
+ )
291
+ if image_data:
292
+ update_fields["image"] = image_data
293
+
294
+ # CRITICAL FIX: Use obs_scene_name if available, otherwise query game_lines for actual game_name
295
+ # The obs_scene_name field stores the immutable OBS scene name (e.g., "君と彼女と彼女の恋。 ver1.00")
296
+ # After update_all_fields_from_jiten(), title_original will be the jiten title (e.g., "君と彼女と彼女の恋。")
297
+
298
+ # First, try to get obs_scene_name from the game record
299
+ obs_scene_name = (
300
+ game.obs_scene_name
301
+ if hasattr(game, "obs_scene_name") and game.obs_scene_name
302
+ else None
303
+ )
304
+
305
+ # If obs_scene_name is not set, query game_lines to find the actual game_name
306
+ if not obs_scene_name:
307
+ result = GameLinesTable._db.fetchone(
308
+ f"SELECT DISTINCT game_name FROM {GameLinesTable._table} WHERE game_id = ? LIMIT 1",
309
+ (game_id,),
310
+ )
311
+ if result and result[0]:
312
+ obs_scene_name = result[0]
313
+ # Store it in obs_scene_name for future use
314
+ game.obs_scene_name = obs_scene_name
315
+ else:
316
+ # Fallback to title_original (this is the old buggy behavior)
317
+ obs_scene_name = game.title_original
318
+ logger.warning(
319
+ f"Could not find game_name in game_lines for game_id={game_id}, falling back to title_original"
320
+ )
321
+
322
+ # Update the game using the jiten update method (doesn't mark as manual)
323
+ game.update_all_fields_from_jiten(**update_fields)
324
+
325
+ # Automatically add Jiten link if links are not manually overridden
326
+ if "links" not in game.manual_overrides:
327
+ add_jiten_link_to_game(game, deck_id)
328
+ # Save the game again to persist the Jiten link
329
+ game.save()
330
+
331
+ # Update ALL game_lines with the OBS scene name to point to this game_id
332
+ # This creates the explicit mapping: OBS scene name -> game_id
333
+ # When a user links a game to jiten.moe, they're saying "this OBS scene name maps to this jiten game"
334
+ lines_updated = 0
335
+ try:
336
+ # Use the obs_scene_name (OBS scene name) to find and update game_lines
337
+ # This will OVERWRITE any existing game_id values
338
+ GameLinesTable._db.execute(
339
+ f"UPDATE {GameLinesTable._table} SET game_id = ? WHERE game_name = ?",
340
+ (game_id, obs_scene_name),
341
+ commit=True,
342
+ )
343
+
344
+ # Count how many lines were updated
345
+ updated_count = GameLinesTable._db.fetchone(
346
+ f"SELECT COUNT(*) FROM {GameLinesTable._table} WHERE game_id = ?",
347
+ (game_id,),
348
+ )
349
+ lines_updated = updated_count[0] if updated_count else 0
350
+
351
+ except Exception as link_error:
352
+ logger.warning(
353
+ f"Failed to update game_lines for game {game_id}: {link_error}"
354
+ )
355
+
356
+ logger.info(f"Linked game {game_id} to jiten.moe deck {deck_id}")
357
+
358
+ return jsonify(
359
+ {
360
+ "success": True,
361
+ "message": f"Game linked to jiten.moe deck {deck_id}",
362
+ "updated_fields": list(update_fields.keys()),
363
+ "manual_overrides": game.manual_overrides,
364
+ "lines_updated": lines_updated,
365
+ }
366
+ ), 200
367
+
368
+ except Exception as e:
369
+ logger.error(f"Error linking game to jiten: {e}")
370
+ return jsonify({"error": f"Failed to link game: {str(e)}"}), 500
371
+
372
+ @app.route("/api/games/<game_id>", methods=["PUT"])
373
+ def api_update_game(game_id):
374
+ """
375
+ Update game information manually (marks fields as manually overridden).
376
+ Supports all game fields including image, deck_id, character_count, and links.
377
+ """
378
+ try:
379
+ from GameSentenceMiner.util.games_table import GamesTable
380
+
381
+ data = request.get_json()
382
+ if not data:
383
+ return jsonify({"error": "No data provided"}), 400
384
+
385
+ # Get the game
386
+ game = GamesTable.get(game_id)
387
+ if not game:
388
+ return jsonify({"error": "Game not found"}), 404
389
+
390
+ # Update fields using manual update method (marks as manual override)
391
+ update_fields = {}
392
+
393
+ # All allowed fields for manual editing
394
+ allowed_fields = [
395
+ "title_original",
396
+ "title_romaji",
397
+ "title_english",
398
+ "type",
399
+ "description",
400
+ "difficulty",
401
+ "completed",
402
+ "deck_id",
403
+ "character_count",
404
+ "image",
405
+ "links",
406
+ "release_date",
407
+ ]
408
+
409
+ for field in allowed_fields:
410
+ if field in data:
411
+ value = data[field]
412
+ # Map 'type' to 'game_type' for the method parameter
413
+ field_key = "game_type" if field == "type" else field
414
+
415
+ # Handle empty strings for optional fields
416
+ if (
417
+ field
418
+ in [
419
+ "title_romaji",
420
+ "title_english",
421
+ "type",
422
+ "description",
423
+ "image",
424
+ "release_date",
425
+ ]
426
+ and value == ""
427
+ ):
428
+ update_fields[field_key] = ""
429
+ # Handle None values for numeric fields
430
+ elif (
431
+ field in ["difficulty", "deck_id", "character_count"]
432
+ and value == ""
433
+ ):
434
+ update_fields[field_key] = None
435
+ # Handle boolean
436
+ elif field == "completed":
437
+ update_fields[field_key] = bool(value)
438
+ # Handle lists
439
+ elif field == "links":
440
+ if isinstance(value, list):
441
+ update_fields[field_key] = value
442
+ elif value == "":
443
+ update_fields[field_key] = []
444
+ else:
445
+ update_fields[field_key] = value
446
+
447
+ if update_fields:
448
+ game.update_all_fields_manual(**update_fields)
449
+
450
+ logger.debug(
451
+ f"Manually updated game {game_id} fields: {list(update_fields.keys())}"
452
+ )
453
+
454
+ return jsonify(
455
+ {
456
+ "success": True,
457
+ "message": "Game updated successfully",
458
+ "updated_fields": list(update_fields.keys()),
459
+ "manual_overrides": game.manual_overrides,
460
+ }
461
+ ), 200
462
+ else:
463
+ return jsonify({"error": "No valid fields to update"}), 400
464
+
465
+ except Exception as e:
466
+ logger.error(f"Error updating game: {e}", exc_info=True)
467
+ return jsonify({"error": f"Failed to update game: {str(e)}"}), 500
468
+
469
+ @app.route("/api/games/<game_id>/mark-complete", methods=["POST"])
470
+ def api_mark_game_complete(game_id):
471
+ """
472
+ Mark a game as completed.
473
+ Sets the completed field to True for the specified game.
474
+ """
475
+ try:
476
+ from GameSentenceMiner.util.games_table import GamesTable
477
+
478
+ # Get the game
479
+ game = GamesTable.get(game_id)
480
+ if not game:
481
+ return jsonify({"error": "Game not found"}), 404
482
+
483
+ # Mark as completed
484
+ game.completed = True
485
+ game.save()
486
+
487
+ logger.debug(f"Marked game {game_id} ({game.title_original}) as completed")
488
+
489
+ return jsonify(
490
+ {
491
+ "success": True,
492
+ "message": f'Game "{game.title_original}" marked as completed',
493
+ "game_id": game_id,
494
+ "completed": True,
495
+ }
496
+ ), 200
497
+
498
+ except Exception as e:
499
+ logger.error(f"Error marking game as complete: {e}", exc_info=True)
500
+ return jsonify({"error": f"Failed to mark game as complete: {str(e)}"}), 500
501
+
502
+ @app.route("/api/games/<game_id>/repull-jiten", methods=["POST"])
503
+ def api_repull_game_from_jiten(game_id):
504
+ """
505
+ Repull jiten.moe data for a game, respecting manual overrides.
506
+ Only updates fields that are not in the manually edited fields list.
507
+ """
508
+ try:
509
+ from GameSentenceMiner.util.games_table import GamesTable
510
+ import requests
511
+
512
+ # Get the game
513
+ game = GamesTable.get(game_id)
514
+ if not game:
515
+ logger.error(f"Game not found: {game_id}")
516
+ return jsonify({"error": "Game not found"}), 404
517
+
518
+ # Check if game is linked to jiten.moe
519
+ if not game.deck_id:
520
+ logger.error(f"Game {game_id} is not linked to jiten.moe")
521
+ return jsonify(
522
+ {"error": "Game is not linked to jiten.moe. Please link it first."}
523
+ ), 400
524
+
525
+ # Fetch fresh data from jiten.moe API using direct deck detail endpoint
526
+ try:
527
+ # Use direct deck detail API endpoint
528
+ data = JitenApiClient.get_deck_detail(game.deck_id)
529
+
530
+ if not data:
531
+ return jsonify(
532
+ {"error": "Failed to fetch data from jiten.moe"}
533
+ ), 500
534
+
535
+ # Extract main deck data from the detail response
536
+ main_deck = data.get("data", {}).get("mainDeck")
537
+ if not main_deck:
538
+ return jsonify(
539
+ {
540
+ "error": f"Game with deck_id {game.deck_id} not found on jiten.moe"
541
+ }
542
+ ), 404
543
+
544
+ # Normalize the deck data
545
+ jiten_data = JitenApiClient.normalize_deck_data(main_deck)
546
+
547
+ except Exception as e:
548
+ logger.error(f"Jiten API request failed: {e}")
549
+ return jsonify({"error": "Failed to fetch data from jiten.moe"}), 500
550
+
551
+ # Update game with fresh jiten.moe data, respecting manual overrides
552
+ update_fields = {}
553
+ skipped_fields = []
554
+
555
+ # Ensure manual_overrides is always a list
556
+ manual_overrides = (
557
+ game.manual_overrides if game.manual_overrides is not None else []
558
+ )
559
+ if not isinstance(manual_overrides, list):
560
+ logger.warning(
561
+ f"manual_overrides is not a list: {type(manual_overrides)} - {manual_overrides}"
562
+ )
563
+ manual_overrides = []
564
+
565
+ # Only update fields that are not manually overridden
566
+ if "deck_id" not in manual_overrides:
567
+ update_fields["deck_id"] = jiten_data["deck_id"]
568
+ else:
569
+ skipped_fields.append("deck_id")
570
+
571
+ if "title_original" not in manual_overrides and jiten_data.get(
572
+ "title_original"
573
+ ):
574
+ update_fields["title_original"] = jiten_data["title_original"]
575
+ elif "title_original" in manual_overrides:
576
+ skipped_fields.append("title_original")
577
+
578
+ if "title_romaji" not in manual_overrides and jiten_data.get(
579
+ "title_romaji"
580
+ ):
581
+ update_fields["title_romaji"] = jiten_data["title_romaji"]
582
+ elif "title_romaji" in manual_overrides:
583
+ skipped_fields.append("title_romaji")
584
+
585
+ if "title_english" not in manual_overrides and jiten_data.get(
586
+ "title_english"
587
+ ):
588
+ update_fields["title_english"] = jiten_data["title_english"]
589
+ elif "title_english" in manual_overrides:
590
+ skipped_fields.append("title_english")
591
+
592
+ if "type" not in manual_overrides and jiten_data.get("media_type_string"):
593
+ # Use the pre-converted media type string from jiten_api_client
594
+ update_fields["game_type"] = jiten_data["media_type_string"]
595
+ elif "type" in manual_overrides:
596
+ skipped_fields.append("type")
597
+
598
+ if "description" not in manual_overrides and jiten_data.get("description"):
599
+ update_fields["description"] = jiten_data["description"]
600
+ elif "description" in manual_overrides:
601
+ skipped_fields.append("description")
602
+
603
+ if (
604
+ "difficulty" not in manual_overrides
605
+ and jiten_data.get("difficulty") is not None
606
+ ):
607
+ update_fields["difficulty"] = jiten_data["difficulty"]
608
+ elif "difficulty" in manual_overrides:
609
+ skipped_fields.append("difficulty")
610
+
611
+ if (
612
+ "character_count" not in manual_overrides
613
+ and jiten_data.get("character_count") is not None
614
+ ):
615
+ update_fields["character_count"] = jiten_data["character_count"]
616
+ elif "character_count" in manual_overrides:
617
+ skipped_fields.append("character_count")
618
+
619
+ if "links" not in manual_overrides and jiten_data.get("links"):
620
+ update_fields["links"] = jiten_data["links"]
621
+ elif "links" in manual_overrides:
622
+ skipped_fields.append("links")
623
+
624
+ if "release_date" not in manual_overrides and jiten_data.get(
625
+ "release_date"
626
+ ):
627
+ update_fields["release_date"] = jiten_data["release_date"]
628
+ elif "release_date" in manual_overrides:
629
+ skipped_fields.append("release_date")
630
+
631
+ # Download and encode image if not manually overridden
632
+ if "image" not in manual_overrides and jiten_data.get("cover_name"):
633
+ image_data = JitenApiClient.download_cover_image(
634
+ jiten_data["cover_name"]
635
+ )
636
+ if image_data:
637
+ update_fields["image"] = image_data
638
+ elif "image" in manual_overrides:
639
+ skipped_fields.append("image")
640
+
641
+ # Update the game using the jiten update method (doesn't mark as manual)
642
+ if update_fields:
643
+ game.update_all_fields_from_jiten(**update_fields)
644
+
645
+ # Automatically add Jiten link if links are not manually overridden
646
+ if "links" not in manual_overrides:
647
+ add_jiten_link_to_game(game, game.deck_id)
648
+ # Save the game again to persist the Jiten link
649
+ game.save()
650
+
651
+ return jsonify(
652
+ {
653
+ "success": True,
654
+ "message": f'Successfully repulled data from jiten.moe for "{game.title_original}"',
655
+ "updated_fields": list(update_fields.keys()),
656
+ "skipped_fields": skipped_fields, # Always return as list
657
+ "deck_id": game.deck_id,
658
+ "jiten_raw_response": jiten_data, # Include full jiten.moe data
659
+ }
660
+ ), 200
661
+ else:
662
+ # Even if no other fields are updated, we should still add the Jiten link if links are not manually overridden
663
+ if "links" not in manual_overrides:
664
+ add_jiten_link_to_game(game, game.deck_id)
665
+ # Save the game to persist the Jiten link
666
+ game.save()
667
+
668
+ return jsonify(
669
+ {
670
+ "success": True,
671
+ "message": f'No fields updated - all fields are manually overridden for "{game.title_original}"',
672
+ "updated_fields": [],
673
+ "skipped_fields": skipped_fields, # Always return as list
674
+ "deck_id": game.deck_id,
675
+ "jiten_raw_response": jiten_data, # Include full jiten.moe data
676
+ }
677
+ ), 200
678
+
679
+ except Exception as e:
680
+ logger.error(
681
+ f"Error repulling jiten data for game {game_id}: {e}", exc_info=True
682
+ )
683
+ return jsonify({"error": f"Failed to repull jiten data: {str(e)}"}), 500
684
+
685
+ @app.route("/api/games/<game_id>", methods=["DELETE"])
686
+ def api_delete_individual_game(game_id):
687
+ """
688
+ Delete (unlink) an individual game from the games table.
689
+ This removes the game record but preserves all game_lines data by setting game_id to NULL.
690
+ """
691
+ try:
692
+ from GameSentenceMiner.util.games_table import GamesTable
693
+
694
+ # Get the game to verify it exists
695
+ game = GamesTable.get(game_id)
696
+ if not game:
697
+ return jsonify({"error": "Game not found"}), 404
698
+
699
+ game_name = game.title_original
700
+
701
+ # Get count of lines that will be unlinked
702
+ lines_count = GameLinesTable._db.fetchone(
703
+ f"SELECT COUNT(*) FROM {GameLinesTable._table} WHERE game_id=?",
704
+ (game_id,),
705
+ )
706
+ unlinked_lines = lines_count[0] if lines_count else 0
707
+
708
+ # Unlink game_lines by setting game_id to NULL
709
+ GameLinesTable._db.execute(
710
+ f"UPDATE {GameLinesTable._table} SET game_id = NULL WHERE game_id = ?",
711
+ (game_id,),
712
+ commit=True,
713
+ )
714
+
715
+ # Delete the game record from games table
716
+ GameLinesTable._db.execute(
717
+ f"DELETE FROM {GamesTable._table} WHERE id = ?", (game_id,), commit=True
718
+ )
719
+
720
+ logger.debug(
721
+ f"Unlinked game '{game_name}' (id={game_id}): removed game record, unlinked {unlinked_lines} lines"
722
+ )
723
+
724
+ # Trigger stats rollup after unlinking game
725
+ try:
726
+ logger.info("Triggering stats rollup after game unlink")
727
+ run_daily_rollup()
728
+ except Exception as rollup_error:
729
+ logger.error(f"Stats rollup failed after game unlink: {rollup_error}")
730
+ # Don't fail the unlink operation if rollup fails
731
+
732
+ return jsonify(
733
+ {
734
+ "success": True,
735
+ "message": f'Game "{game_name}" has been unlinked successfully',
736
+ "game_name": game_name,
737
+ "unlinked_lines": unlinked_lines,
738
+ }
739
+ ), 200
740
+
741
+ except Exception as e:
742
+ logger.error(f"Error unlinking game {game_id}: {e}", exc_info=True)
743
+ return jsonify({"error": f"Failed to unlink game: {str(e)}"}), 500
744
+
745
+ @app.route("/api/games/<game_id>/delete-lines", methods=["DELETE"])
746
+ def api_delete_game_lines(game_id):
747
+ """
748
+ Permanently delete all lines associated with a game.
749
+ This is a destructive operation that cannot be undone.
750
+ """
751
+ try:
752
+ from GameSentenceMiner.util.games_table import GamesTable
753
+
754
+ # Get the game to verify it exists
755
+ game = GamesTable.get(game_id)
756
+ if not game:
757
+ return jsonify({"error": "Game not found"}), 404
758
+
759
+ game_name = game.title_original
760
+
761
+ # Get count of lines that will be deleted
762
+ lines_count = GameLinesTable._db.fetchone(
763
+ f"SELECT COUNT(*) FROM {GameLinesTable._table} WHERE game_id=?",
764
+ (game_id,),
765
+ )
766
+ lines_to_delete = lines_count[0] if lines_count else 0
767
+
768
+ if lines_to_delete == 0:
769
+ return jsonify(
770
+ {"error": "No lines found for this game"}
771
+ ), 404
772
+
773
+ # PERMANENTLY DELETE all lines for this game
774
+ GameLinesTable._db.execute(
775
+ f"DELETE FROM {GameLinesTable._table} WHERE game_id = ?",
776
+ (game_id,),
777
+ commit=True,
778
+ )
779
+
780
+ # Also delete the game record from games table
781
+ GameLinesTable._db.execute(
782
+ f"DELETE FROM {GamesTable._table} WHERE id = ?", (game_id,), commit=True
783
+ )
784
+
785
+ logger.info(
786
+ f"PERMANENTLY DELETED game '{game_name}' (id={game_id}): deleted {lines_to_delete} lines and game record"
787
+ )
788
+
789
+ # Trigger stats rollup after deleting game lines
790
+ try:
791
+ logger.info("Triggering stats rollup after game lines deletion")
792
+ run_daily_rollup()
793
+ except Exception as rollup_error:
794
+ logger.error(f"Stats rollup failed after game lines deletion: {rollup_error}")
795
+ # Don't fail the deletion operation if rollup fails
796
+
797
+ return jsonify(
798
+ {
799
+ "success": True,
800
+ "message": f'Game lines for "{game_name}" have been PERMANENTLY DELETED',
801
+ "game_name": game_name,
802
+ "deleted_lines": lines_to_delete,
803
+ }
804
+ ), 200
805
+
806
+ except Exception as e:
807
+ logger.error(f"Error deleting game lines for {game_id}: {e}", exc_info=True)
808
+ return jsonify({"error": f"Failed to delete game lines: {str(e)}"}), 500
809
+
810
+ @app.route("/api/orphaned-games", methods=["GET"])
811
+ def api_orphaned_games():
812
+ """
813
+ Get game names from game_lines that don't have corresponding games records.
814
+ Returns potential games that users can choose to create.
815
+ """
816
+ try:
817
+ from GameSentenceMiner.util.games_table import GamesTable
818
+
819
+ # Get all distinct game names from game_lines
820
+ game_names_from_lines = GameLinesTable._db.fetchall(
821
+ f"SELECT DISTINCT game_name, COUNT(*) as line_count, SUM(LENGTH(line_text)) as char_count "
822
+ f"FROM {GameLinesTable._table} "
823
+ f"WHERE game_name IS NOT NULL AND game_name != '' "
824
+ f"GROUP BY game_name"
825
+ )
826
+
827
+ # Get all existing game titles from games table
828
+ existing_games = GamesTable._db.fetchall(
829
+ f"SELECT title_original FROM {GamesTable._table}"
830
+ )
831
+ existing_titles = {row[0] for row in existing_games}
832
+
833
+ # Find orphaned games (in game_lines but not in games table)
834
+ orphaned_games = []
835
+ for row in game_names_from_lines:
836
+ game_name, line_count, char_count = row
837
+ if game_name not in existing_titles:
838
+ # Get date range for this game
839
+ date_range = GameLinesTable._db.fetchone(
840
+ f"SELECT MIN(timestamp), MAX(timestamp) FROM {GameLinesTable._table} WHERE game_name=?",
841
+ (game_name,),
842
+ )
843
+ min_timestamp, max_timestamp = (
844
+ date_range if date_range else (None, None)
845
+ )
846
+
847
+ orphaned_games.append(
848
+ {
849
+ "game_name": game_name,
850
+ "line_count": line_count,
851
+ "character_count": char_count or 0,
852
+ "first_seen": min_timestamp,
853
+ "last_seen": max_timestamp,
854
+ }
855
+ )
856
+
857
+ # Sort by character count (most active first)
858
+ orphaned_games.sort(key=lambda x: x["character_count"], reverse=True)
859
+
860
+ return jsonify(
861
+ {
862
+ "orphaned_games": orphaned_games,
863
+ "total_orphaned": len(orphaned_games),
864
+ "total_managed": len(existing_titles),
865
+ }
866
+ ), 200
867
+
868
+ except Exception as e:
869
+ logger.error(f"Error fetching orphaned games: {e}")
870
+ return jsonify({"error": "Failed to fetch orphaned games"}), 500
871
+
872
+ @app.route("/api/games", methods=["POST"])
873
+ def api_create_game():
874
+ """
875
+ Create a new game record (custom or from jiten.moe data).
876
+ Links orphaned game_lines to the newly created game.
877
+ """
878
+ try:
879
+ from GameSentenceMiner.util.games_table import GamesTable
880
+
881
+ data = request.get_json()
882
+ if not data:
883
+ return jsonify({"error": "No data provided"}), 400
884
+
885
+ # Required field
886
+ title_original = data.get("title_original", "").strip()
887
+ if not title_original:
888
+ return jsonify({"error": "title_original is required"}), 400
889
+
890
+ # Check if game already exists
891
+ existing_game = GamesTable.get_by_title(title_original)
892
+ if existing_game:
893
+ return jsonify(
894
+ {"error": f'Game with title "{title_original}" already exists'}
895
+ ), 400
896
+
897
+ # Create new game with provided data
898
+ game_data = {
899
+ "title_original": title_original,
900
+ "title_romaji": data.get("title_romaji", ""),
901
+ "title_english": data.get("title_english", ""),
902
+ "game_type": data.get("type", ""),
903
+ "description": data.get("description", ""),
904
+ "image": data.get("image", ""),
905
+ "difficulty": data.get("difficulty"),
906
+ "links": data.get("links", []),
907
+ "completed": data.get("completed", False),
908
+ }
909
+
910
+ # Create the game
911
+ new_game = GamesTable(**game_data)
912
+ new_game.add() # Use add() instead of save() for new records with UUID primary keys
913
+
914
+ # Link orphaned game_lines to this new game
915
+ lines_updated = 0
916
+ try:
917
+ GameLinesTable._db.execute(
918
+ f"UPDATE {GameLinesTable._table} SET game_id = ? WHERE game_name = ? AND (game_id IS NULL OR game_id = '')",
919
+ (new_game.id, title_original),
920
+ commit=True,
921
+ )
922
+
923
+ # Count how many lines were updated
924
+ updated_count = GameLinesTable._db.fetchone(
925
+ f"SELECT COUNT(*) FROM {GameLinesTable._table} WHERE game_id = ?",
926
+ (new_game.id,),
927
+ )
928
+ lines_updated = updated_count[0] if updated_count else 0
929
+
930
+ # Don't update character_count - it should only store jiten.moe's total
931
+ # Mined character count is calculated on-the-fly from game_lines
932
+
933
+ except Exception as link_error:
934
+ logger.warning(
935
+ f"Failed to link orphaned lines to new game {new_game.id}: {link_error}"
936
+ )
937
+
938
+ logger.debug(
939
+ f"Created new game: {title_original} (id={new_game.id}, linked {lines_updated} lines)"
940
+ )
941
+
942
+ return (
943
+ jsonify(
944
+ {
945
+ "success": True,
946
+ "message": f'Game "{title_original}" created successfully',
947
+ "game": {
948
+ "id": new_game.id,
949
+ "title_original": new_game.title_original,
950
+ "title_romaji": new_game.title_romaji,
951
+ "title_english": new_game.title_english,
952
+ "type": new_game.type,
953
+ "jiten_character_count": new_game.character_count, # Jiten total (if linked)
954
+ "lines_linked": lines_updated,
955
+ },
956
+ }
957
+ ),
958
+ 201,
959
+ )
960
+
961
+ except Exception as e:
962
+ logger.error(f"Error creating game: {e}")
963
+ return jsonify({"error": f"Failed to create game: {str(e)}"}), 500
964
+
965
+ @app.route("/api/debug-db", methods=["GET"])
966
+ def api_debug_db():
967
+ """Debug endpoint to check database structure and content."""
968
+ try:
969
+ # Check table structure
970
+ columns_info = GameLinesTable._db.fetchall("PRAGMA table_info(game_lines)")
971
+ table_structure = [
972
+ {"name": col[1], "type": col[2], "notnull": col[3], "default": col[4]}
973
+ for col in columns_info
974
+ ]
975
+
976
+ # Check if we have any data
977
+ count_result = GameLinesTable._db.fetchone(
978
+ "SELECT COUNT(*) FROM game_lines"
979
+ )
980
+ total_count = count_result[0] if count_result else 0
981
+
982
+ # Try to get a sample record
983
+ sample_record = None
984
+ if total_count > 0:
985
+ sample_row = GameLinesTable._db.fetchone(
986
+ "SELECT * FROM game_lines LIMIT 1"
987
+ )
988
+ if sample_row:
989
+ sample_record = {
990
+ "row_length": len(sample_row),
991
+ "sample_data": sample_row[:5]
992
+ if len(sample_row) > 5
993
+ else sample_row, # First 5 columns only
994
+ }
995
+
996
+ # Test the model
997
+ model_info = {
998
+ "fields_count": len(GameLinesTable._fields),
999
+ "types_count": len(GameLinesTable._types),
1000
+ "fields": GameLinesTable._fields,
1001
+ "types": [str(t) for t in GameLinesTable._types],
1002
+ }
1003
+
1004
+ return jsonify(
1005
+ {
1006
+ "table_structure": table_structure,
1007
+ "total_records": total_count,
1008
+ "sample_record": sample_record,
1009
+ "model_info": model_info,
1010
+ }
1011
+ ), 200
1012
+
1013
+ except Exception as e:
1014
+ logger.error(f"Error in debug endpoint: {e}")
1015
+ return jsonify({"error": f"Debug failed: {str(e)}"}), 500