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.
- GameSentenceMiner/__init__.py +39 -0
- GameSentenceMiner/anki.py +6 -3
- GameSentenceMiner/gametext.py +13 -2
- GameSentenceMiner/gsm.py +40 -3
- GameSentenceMiner/locales/en_us.json +4 -0
- GameSentenceMiner/locales/ja_jp.json +4 -0
- GameSentenceMiner/locales/zh_cn.json +4 -0
- GameSentenceMiner/obs.py +4 -1
- GameSentenceMiner/owocr/owocr/ocr.py +304 -134
- GameSentenceMiner/owocr/owocr/run.py +1 -1
- GameSentenceMiner/ui/anki_confirmation.py +4 -2
- GameSentenceMiner/ui/config_gui.py +12 -0
- GameSentenceMiner/util/configuration.py +6 -2
- GameSentenceMiner/util/cron/__init__.py +12 -0
- GameSentenceMiner/util/cron/daily_rollup.py +613 -0
- GameSentenceMiner/util/cron/jiten_update.py +397 -0
- GameSentenceMiner/util/cron/populate_games.py +154 -0
- GameSentenceMiner/util/cron/run_crons.py +148 -0
- GameSentenceMiner/util/cron/setup_populate_games_cron.py +118 -0
- GameSentenceMiner/util/cron_table.py +334 -0
- GameSentenceMiner/util/db.py +236 -49
- GameSentenceMiner/util/ffmpeg.py +23 -4
- GameSentenceMiner/util/games_table.py +340 -93
- GameSentenceMiner/util/jiten_api_client.py +188 -0
- GameSentenceMiner/util/stats_rollup_table.py +216 -0
- GameSentenceMiner/web/anki_api_endpoints.py +438 -220
- GameSentenceMiner/web/database_api.py +955 -1259
- GameSentenceMiner/web/jiten_database_api.py +1015 -0
- GameSentenceMiner/web/rollup_stats.py +672 -0
- GameSentenceMiner/web/static/css/dashboard-shared.css +75 -13
- GameSentenceMiner/web/static/css/overview.css +604 -47
- GameSentenceMiner/web/static/css/search.css +226 -0
- GameSentenceMiner/web/static/css/shared.css +762 -0
- GameSentenceMiner/web/static/css/stats.css +221 -0
- GameSentenceMiner/web/static/js/components/bar-chart.js +339 -0
- GameSentenceMiner/web/static/js/database-bulk-operations.js +320 -0
- GameSentenceMiner/web/static/js/database-game-data.js +390 -0
- GameSentenceMiner/web/static/js/database-game-operations.js +213 -0
- GameSentenceMiner/web/static/js/database-helpers.js +44 -0
- GameSentenceMiner/web/static/js/database-jiten-integration.js +750 -0
- GameSentenceMiner/web/static/js/database-popups.js +89 -0
- GameSentenceMiner/web/static/js/database-tabs.js +64 -0
- GameSentenceMiner/web/static/js/database-text-management.js +371 -0
- GameSentenceMiner/web/static/js/database.js +86 -718
- GameSentenceMiner/web/static/js/goals.js +79 -18
- GameSentenceMiner/web/static/js/heatmap.js +29 -23
- GameSentenceMiner/web/static/js/overview.js +1205 -339
- GameSentenceMiner/web/static/js/regex-patterns.js +100 -0
- GameSentenceMiner/web/static/js/search.js +215 -18
- GameSentenceMiner/web/static/js/shared.js +193 -39
- GameSentenceMiner/web/static/js/stats.js +1536 -179
- GameSentenceMiner/web/stats.py +1142 -269
- GameSentenceMiner/web/stats_api.py +2104 -0
- GameSentenceMiner/web/templates/anki_stats.html +4 -18
- GameSentenceMiner/web/templates/components/date-range.html +118 -3
- GameSentenceMiner/web/templates/components/html-head.html +40 -6
- GameSentenceMiner/web/templates/components/js-config.html +8 -8
- GameSentenceMiner/web/templates/components/regex-input.html +160 -0
- GameSentenceMiner/web/templates/database.html +564 -117
- GameSentenceMiner/web/templates/goals.html +41 -5
- GameSentenceMiner/web/templates/overview.html +159 -129
- GameSentenceMiner/web/templates/search.html +78 -9
- GameSentenceMiner/web/templates/stats.html +159 -5
- GameSentenceMiner/web/texthooking_page.py +280 -111
- {gamesentenceminer-2.19.16.dist-info → gamesentenceminer-2.20.0.dist-info}/METADATA +43 -2
- {gamesentenceminer-2.19.16.dist-info → gamesentenceminer-2.20.0.dist-info}/RECORD +70 -47
- {gamesentenceminer-2.19.16.dist-info → gamesentenceminer-2.20.0.dist-info}/WHEEL +0 -0
- {gamesentenceminer-2.19.16.dist-info → gamesentenceminer-2.20.0.dist-info}/entry_points.txt +0 -0
- {gamesentenceminer-2.19.16.dist-info → gamesentenceminer-2.20.0.dist-info}/licenses/LICENSE +0 -0
- {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
|