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
GameSentenceMiner/util/db.py
CHANGED
|
@@ -211,9 +211,29 @@ class SQLiteDBTable:
|
|
|
211
211
|
elif field_type is int:
|
|
212
212
|
setattr(obj, field, int(row_value) if row_value is not None else None)
|
|
213
213
|
elif field_type is float:
|
|
214
|
-
|
|
214
|
+
if row_value is None:
|
|
215
|
+
setattr(obj, field, None)
|
|
216
|
+
elif isinstance(row_value, str):
|
|
217
|
+
# Try to parse datetime strings to Unix timestamp
|
|
218
|
+
try:
|
|
219
|
+
# First try direct float conversion
|
|
220
|
+
setattr(obj, field, float(row_value))
|
|
221
|
+
except ValueError:
|
|
222
|
+
# If that fails, try parsing as datetime string
|
|
223
|
+
try:
|
|
224
|
+
from datetime import datetime
|
|
225
|
+
dt = datetime.fromisoformat(row_value.replace(' ', 'T'))
|
|
226
|
+
setattr(obj, field, dt.timestamp())
|
|
227
|
+
except (ValueError, AttributeError):
|
|
228
|
+
# If all parsing fails, set to None
|
|
229
|
+
logger.warning(f"Could not convert '{row_value}' to float or datetime, setting to None")
|
|
230
|
+
setattr(obj, field, None)
|
|
231
|
+
else:
|
|
232
|
+
setattr(obj, field, float(row_value))
|
|
215
233
|
elif field_type is bool:
|
|
216
|
-
|
|
234
|
+
# Convert from SQLite: 0/1 (int), '0'/'1' (str), or None -> bool
|
|
235
|
+
# Default to False for None/empty, True only for 1 or '1'
|
|
236
|
+
setattr(obj, field, row_value == 1 or row_value == '1')
|
|
217
237
|
elif field_type is dict:
|
|
218
238
|
try:
|
|
219
239
|
setattr(obj, field, json.loads(row_value) if row_value else {})
|
|
@@ -225,8 +245,12 @@ class SQLiteDBTable:
|
|
|
225
245
|
def save(self, retry=1):
|
|
226
246
|
try:
|
|
227
247
|
for field in self._fields:
|
|
228
|
-
|
|
229
|
-
|
|
248
|
+
field_value = getattr(self, field)
|
|
249
|
+
if isinstance(field_value, list):
|
|
250
|
+
setattr(self, field, json.dumps(field_value))
|
|
251
|
+
elif isinstance(field_value, bool):
|
|
252
|
+
# Convert boolean to integer (0 or 1) for SQLite storage
|
|
253
|
+
setattr(self, field, 1 if field_value else 0)
|
|
230
254
|
data = {field: getattr(self, field) for field in self._fields}
|
|
231
255
|
pk_val = getattr(self, self._pk, None)
|
|
232
256
|
if pk_val is None:
|
|
@@ -266,6 +290,15 @@ class SQLiteDBTable:
|
|
|
266
290
|
raise ValueError(
|
|
267
291
|
f"Primary key {self._pk} must be set for non-auto-increment tables.")
|
|
268
292
|
else:
|
|
293
|
+
# Serialize list and dict fields to JSON, convert booleans to integers
|
|
294
|
+
for field in self._fields:
|
|
295
|
+
field_value = getattr(self, field)
|
|
296
|
+
if isinstance(field_value, (list, dict)):
|
|
297
|
+
setattr(self, field, json.dumps(field_value))
|
|
298
|
+
elif isinstance(field_value, bool):
|
|
299
|
+
# Convert boolean to integer (0 or 1) for SQLite storage
|
|
300
|
+
setattr(self, field, 1 if field_value else 0)
|
|
301
|
+
|
|
269
302
|
keys = ', '.join(self._fields + [self._pk])
|
|
270
303
|
placeholders = ', '.join(['?'] * (len(self._fields) + 1))
|
|
271
304
|
values = tuple(getattr(self, field)
|
|
@@ -530,9 +563,10 @@ class GameLinesTable(SQLiteDBTable):
|
|
|
530
563
|
logger.debug(f"Updated GameLine id={line_id} paths.")
|
|
531
564
|
|
|
532
565
|
@classmethod
|
|
533
|
-
def add_line(cls, gameline: GameLine):
|
|
566
|
+
def add_line(cls, gameline: GameLine, game_id: Optional[str] = None):
|
|
534
567
|
new_line = cls(id=gameline.id, game_name=gameline.scene,
|
|
535
|
-
line_text=gameline.text, timestamp=gameline.time.timestamp()
|
|
568
|
+
line_text=gameline.text, timestamp=gameline.time.timestamp(),
|
|
569
|
+
game_id=game_id if game_id else '')
|
|
536
570
|
# logger.info("Adding GameLine to DB: %s", new_line)
|
|
537
571
|
new_line.add()
|
|
538
572
|
return new_line
|
|
@@ -581,47 +615,6 @@ class GameLinesTable(SQLiteDBTable):
|
|
|
581
615
|
clean_columns = ['line_text'] if for_stats else []
|
|
582
616
|
return [cls.from_row(row, clean_columns=clean_columns) for row in rows]
|
|
583
617
|
|
|
584
|
-
class StatsRollupTable(SQLiteDBTable):
|
|
585
|
-
_table = 'stats_rollup'
|
|
586
|
-
_fields = ['date', 'games_played', 'lines_mined', 'anki_cards_created', 'time_spent_mining']
|
|
587
|
-
_types = [int, # Includes primary key type
|
|
588
|
-
str, int, int, int, float]
|
|
589
|
-
_pk = 'id'
|
|
590
|
-
_auto_increment = True # Use auto-incrementing integer IDs
|
|
591
|
-
|
|
592
|
-
def __init__(self, id: Optional[int] = None,
|
|
593
|
-
date: Optional[str] = None,
|
|
594
|
-
games_played: int = 0,
|
|
595
|
-
lines_mined: int = 0,
|
|
596
|
-
anki_cards_created: int = 0,
|
|
597
|
-
time_spent_mining: float = 0.0):
|
|
598
|
-
self.id = id
|
|
599
|
-
self.date = date if date is not None else datetime.now().strftime("%Y-%m-%d")
|
|
600
|
-
self.games_played = games_played
|
|
601
|
-
self.lines_mined = lines_mined
|
|
602
|
-
self.anki_cards_created = anki_cards_created
|
|
603
|
-
self.time_spent_mining = time_spent_mining
|
|
604
|
-
|
|
605
|
-
@classmethod
|
|
606
|
-
def get_stats_for_date(cls, date: str) -> Optional['StatsRollupTable']:
|
|
607
|
-
row = cls._db.fetchone(
|
|
608
|
-
f"SELECT * FROM {cls._table} WHERE date=?", (date,))
|
|
609
|
-
return cls.from_row(row) if row else None
|
|
610
|
-
|
|
611
|
-
@classmethod
|
|
612
|
-
def update_stats(cls, date: str, games_played: int = 0, lines_mined: int = 0, anki_cards_created: int = 0, time_spent_mining: float = 0.0):
|
|
613
|
-
stats = cls.get_stats_for_date(date)
|
|
614
|
-
if not stats:
|
|
615
|
-
new_stats = cls(date=date, games_played=games_played,
|
|
616
|
-
lines_mined=lines_mined, anki_cards_created=anki_cards_created, time_spent_mining=time_spent_mining)
|
|
617
|
-
new_stats.save()
|
|
618
|
-
return
|
|
619
|
-
stats.games_played += games_played
|
|
620
|
-
stats.lines_mined += lines_mined
|
|
621
|
-
stats.anki_cards_created += anki_cards_created
|
|
622
|
-
stats.time_spent_mining += time_spent_mining
|
|
623
|
-
stats.save()
|
|
624
|
-
|
|
625
618
|
# Ensure database directory exists and return path
|
|
626
619
|
def get_db_directory(test=False, delete_test=False) -> str:
|
|
627
620
|
if platform == 'win32': # Windows
|
|
@@ -640,6 +633,16 @@ def get_db_directory(test=False, delete_test=False) -> str:
|
|
|
640
633
|
|
|
641
634
|
# Backup and compress the database on load, with today's date, up to 5 days ago (clean up old backups)
|
|
642
635
|
def backup_db(db_path: str):
|
|
636
|
+
|
|
637
|
+
# Create a backup of the backups on migration
|
|
638
|
+
pre_jiten_merge_backup = os.path.join(os.path.dirname(db_path), "backup", "database", "pre_jiten")
|
|
639
|
+
if not os.path.exists(pre_jiten_merge_backup):
|
|
640
|
+
os.makedirs(pre_jiten_merge_backup, exist_ok=True)
|
|
641
|
+
for fname in os.listdir(os.path.join(os.path.dirname(db_path), "backup", "database")):
|
|
642
|
+
fpath = os.path.join(os.path.dirname(db_path), "backup", "database", fname)
|
|
643
|
+
if os.path.isfile(fpath):
|
|
644
|
+
shutil.copy2(fpath, pre_jiten_merge_backup)
|
|
645
|
+
|
|
643
646
|
backup_dir = os.path.join(os.path.dirname(db_path), "backup", "database")
|
|
644
647
|
os.makedirs(backup_dir, exist_ok=True)
|
|
645
648
|
today = time.strftime("%Y-%m-%d")
|
|
@@ -675,10 +678,12 @@ if os.path.exists(db_path):
|
|
|
675
678
|
|
|
676
679
|
gsm_db = SQLiteDB(db_path)
|
|
677
680
|
|
|
678
|
-
# Import GamesTable after gsm_db is created to avoid circular import
|
|
681
|
+
# Import GamesTable, CronTable, and StatsRollupTable after gsm_db is created to avoid circular import
|
|
679
682
|
from GameSentenceMiner.util.games_table import GamesTable
|
|
683
|
+
from GameSentenceMiner.util.cron_table import CronTable
|
|
684
|
+
from GameSentenceMiner.util.stats_rollup_table import StatsRollupTable
|
|
680
685
|
|
|
681
|
-
for cls in [AIModelsTable, GameLinesTable, GamesTable]:
|
|
686
|
+
for cls in [AIModelsTable, GameLinesTable, GamesTable, CronTable, StatsRollupTable]:
|
|
682
687
|
cls.set_db(gsm_db)
|
|
683
688
|
# Uncomment to start fresh every time
|
|
684
689
|
# cls.drop()
|
|
@@ -700,7 +705,189 @@ def check_and_run_migrations():
|
|
|
700
705
|
GameLinesTable.alter_column_type('timestamp_old', 'timestamp', 'REAL')
|
|
701
706
|
logger.info("Migrated 'timestamp' column to REAL type in GameLinesTable.")
|
|
702
707
|
|
|
708
|
+
def migrate_obs_scene_name():
|
|
709
|
+
"""
|
|
710
|
+
Add obs_scene_name column to games table and populate it from game_lines.
|
|
711
|
+
This migration ensures existing games have their OBS scene names preserved.
|
|
712
|
+
"""
|
|
713
|
+
if not GamesTable.has_column('obs_scene_name'):
|
|
714
|
+
logger.info("Adding 'obs_scene_name' column to games table...")
|
|
715
|
+
GamesTable._db.execute(
|
|
716
|
+
f"ALTER TABLE {GamesTable._table} ADD COLUMN obs_scene_name TEXT",
|
|
717
|
+
commit=True
|
|
718
|
+
)
|
|
719
|
+
logger.info("Added 'obs_scene_name' column to games table.")
|
|
720
|
+
|
|
721
|
+
# Populate obs_scene_name for existing games by querying game_lines
|
|
722
|
+
logger.info("Populating obs_scene_name from game_lines...")
|
|
723
|
+
all_games = GamesTable.all()
|
|
724
|
+
updated_count = 0
|
|
725
|
+
|
|
726
|
+
for game in all_games:
|
|
727
|
+
# Find the first game_line with this game_id to get the original game_name
|
|
728
|
+
result = GameLinesTable._db.fetchone(
|
|
729
|
+
f"SELECT game_name FROM {GameLinesTable._table} WHERE game_id=? LIMIT 1",
|
|
730
|
+
(game.id,)
|
|
731
|
+
)
|
|
732
|
+
|
|
733
|
+
if result and result[0]:
|
|
734
|
+
obs_scene_name = result[0]
|
|
735
|
+
# Update the game with the obs_scene_name
|
|
736
|
+
GamesTable._db.execute(
|
|
737
|
+
f"UPDATE {GamesTable._table} SET obs_scene_name=? WHERE id=?",
|
|
738
|
+
(obs_scene_name, game.id),
|
|
739
|
+
commit=True
|
|
740
|
+
)
|
|
741
|
+
updated_count += 1
|
|
742
|
+
logger.debug(f"Set obs_scene_name='{obs_scene_name}' for game id={game.id}")
|
|
743
|
+
|
|
744
|
+
logger.info(f"Migration complete: Updated {updated_count} games with obs_scene_name from game_lines.")
|
|
745
|
+
else:
|
|
746
|
+
logger.debug("obs_scene_name column already exists in games table, skipping migration.")
|
|
747
|
+
"""
|
|
748
|
+
Convert datetime strings in cron_table to Unix timestamps.
|
|
749
|
+
This migration handles legacy data that may have datetime strings instead of floats.
|
|
750
|
+
"""
|
|
751
|
+
try:
|
|
752
|
+
# Get all rows directly from database to check for datetime strings
|
|
753
|
+
rows = CronTable._db.fetchall(f"SELECT id, last_run, next_run, created_at FROM {CronTable._table}")
|
|
754
|
+
|
|
755
|
+
updates_needed = []
|
|
756
|
+
for row in rows:
|
|
757
|
+
cron_id, last_run, next_run, created_at = row
|
|
758
|
+
needs_update = False
|
|
759
|
+
new_last_run = last_run
|
|
760
|
+
new_next_run = next_run
|
|
761
|
+
new_created_at = created_at
|
|
762
|
+
|
|
763
|
+
# Check and convert last_run
|
|
764
|
+
if last_run and isinstance(last_run, str) and not last_run.replace('.', '', 1).isdigit():
|
|
765
|
+
try:
|
|
766
|
+
dt = datetime.fromisoformat(last_run.replace(' ', 'T'))
|
|
767
|
+
new_last_run = dt.timestamp()
|
|
768
|
+
needs_update = True
|
|
769
|
+
except (ValueError, AttributeError):
|
|
770
|
+
logger.warning(f"Could not parse last_run '{last_run}' for cron id={cron_id}")
|
|
771
|
+
new_last_run = None
|
|
772
|
+
needs_update = True
|
|
773
|
+
|
|
774
|
+
# Check and convert next_run
|
|
775
|
+
if next_run and isinstance(next_run, str) and not next_run.replace('.', '', 1).isdigit():
|
|
776
|
+
try:
|
|
777
|
+
dt = datetime.fromisoformat(next_run.replace(' ', 'T'))
|
|
778
|
+
new_next_run = dt.timestamp()
|
|
779
|
+
needs_update = True
|
|
780
|
+
except (ValueError, AttributeError):
|
|
781
|
+
logger.warning(f"Could not parse next_run '{next_run}' for cron id={cron_id}")
|
|
782
|
+
new_next_run = time.time()
|
|
783
|
+
needs_update = True
|
|
784
|
+
|
|
785
|
+
# Check and convert created_at
|
|
786
|
+
if created_at and isinstance(created_at, str) and not created_at.replace('.', '', 1).isdigit():
|
|
787
|
+
try:
|
|
788
|
+
dt = datetime.fromisoformat(created_at.replace(' ', 'T'))
|
|
789
|
+
new_created_at = dt.timestamp()
|
|
790
|
+
needs_update = True
|
|
791
|
+
except (ValueError, AttributeError):
|
|
792
|
+
logger.warning(f"Could not parse created_at '{created_at}' for cron id={cron_id}")
|
|
793
|
+
new_created_at = time.time()
|
|
794
|
+
needs_update = True
|
|
795
|
+
|
|
796
|
+
if needs_update:
|
|
797
|
+
updates_needed.append((new_last_run, new_next_run, new_created_at, cron_id))
|
|
798
|
+
|
|
799
|
+
# Apply updates
|
|
800
|
+
if updates_needed:
|
|
801
|
+
logger.info(f"Migrating {len(updates_needed)} cron entries with datetime strings to Unix timestamps...")
|
|
802
|
+
for new_last_run, new_next_run, new_created_at, cron_id in updates_needed:
|
|
803
|
+
CronTable._db.execute(
|
|
804
|
+
f"UPDATE {CronTable._table} SET last_run=?, next_run=?, created_at=? WHERE id=?",
|
|
805
|
+
(new_last_run, new_next_run, new_created_at, cron_id),
|
|
806
|
+
commit=True
|
|
807
|
+
)
|
|
808
|
+
logger.info(f"✅ Migrated {len(updates_needed)} cron entries to Unix timestamps")
|
|
809
|
+
else:
|
|
810
|
+
logger.debug("No cron timestamp migration needed")
|
|
811
|
+
|
|
812
|
+
except Exception as e:
|
|
813
|
+
logger.error(f"Error during cron timestamp migration: {e}")
|
|
814
|
+
|
|
815
|
+
def migrate_jiten_cron_job():
|
|
816
|
+
"""
|
|
817
|
+
Create the monthly jiten.moe update cron job if it doesn't exist.
|
|
818
|
+
This ensures the cron job is automatically registered on database initialization.
|
|
819
|
+
"""
|
|
820
|
+
existing_cron = CronTable.get_by_name('jiten_sync')
|
|
821
|
+
if not existing_cron:
|
|
822
|
+
logger.info("Creating monthly jiten.moe update cron job...")
|
|
823
|
+
# Calculate next run: first day of next month at midnight
|
|
824
|
+
now = datetime.now()
|
|
825
|
+
if now.month == 12:
|
|
826
|
+
next_month = datetime(now.year + 1, 1, 1, 0, 0, 0)
|
|
827
|
+
else:
|
|
828
|
+
next_month = datetime(now.year, now.month + 1, 1, 0, 0, 0)
|
|
829
|
+
|
|
830
|
+
CronTable.create_cron_entry(
|
|
831
|
+
name='jiten_sync',
|
|
832
|
+
description='Automatically update all linked games from jiten.moe database (respects manual overrides)',
|
|
833
|
+
next_run=next_month.timestamp(),
|
|
834
|
+
schedule='monthly'
|
|
835
|
+
)
|
|
836
|
+
logger.info(f"✅ Created jiten_sync cron job - next run: {next_month.strftime('%Y-%m-%d %H:%M:%S')}")
|
|
837
|
+
else:
|
|
838
|
+
logger.debug("jiten_sync cron job already exists, skipping creation.")
|
|
839
|
+
|
|
840
|
+
def migrate_daily_rollup_cron_job():
|
|
841
|
+
"""
|
|
842
|
+
Create the daily statistics rollup cron job if it doesn't exist.
|
|
843
|
+
This ensures the cron job is automatically registered on database initialization.
|
|
844
|
+
"""
|
|
845
|
+
existing_cron = CronTable.get_by_name('daily_stats_rollup')
|
|
846
|
+
if not existing_cron:
|
|
847
|
+
logger.info("Creating daily statistics rollup cron job...")
|
|
848
|
+
# Schedule for 1 minute ago to ensure it runs immediately on first startup
|
|
849
|
+
now = datetime.now()
|
|
850
|
+
one_minute_ago = now - timedelta(minutes=1)
|
|
851
|
+
|
|
852
|
+
CronTable.create_cron_entry(
|
|
853
|
+
name='daily_stats_rollup',
|
|
854
|
+
description='Roll up daily statistics for all dates up to yesterday',
|
|
855
|
+
next_run=one_minute_ago.timestamp(),
|
|
856
|
+
schedule='daily'
|
|
857
|
+
)
|
|
858
|
+
logger.info(f"✅ Created daily_stats_rollup cron job - scheduled to run immediately (next_run: {one_minute_ago.strftime('%Y-%m-%d %H:%M:%S')})")
|
|
859
|
+
else:
|
|
860
|
+
logger.debug("daily_stats_rollup cron job already exists, skipping creation.")
|
|
861
|
+
|
|
862
|
+
def migrate_populate_games_cron_job():
|
|
863
|
+
"""
|
|
864
|
+
Create the one-time populate_games cron job if it doesn't exist.
|
|
865
|
+
This ensures games table is populated before the daily rollup runs.
|
|
866
|
+
Runs once and auto-disables (schedule='once').
|
|
867
|
+
"""
|
|
868
|
+
existing_cron = CronTable.get_by_name('populate_games')
|
|
869
|
+
if not existing_cron:
|
|
870
|
+
logger.info("Creating one-time populate_games cron job...")
|
|
871
|
+
# Schedule to run immediately (2 minutes ago to ensure it runs before rollup)
|
|
872
|
+
now = datetime.now()
|
|
873
|
+
two_minutes_ago = now - timedelta(minutes=2)
|
|
874
|
+
|
|
875
|
+
CronTable.create_cron_entry(
|
|
876
|
+
name='populate_games',
|
|
877
|
+
description='One-time auto-creation of game records from game_lines (runs before rollup)',
|
|
878
|
+
next_run=two_minutes_ago.timestamp(),
|
|
879
|
+
schedule='weekly' # Will auto-disable after running
|
|
880
|
+
)
|
|
881
|
+
logger.info(f"✅ Created populate_games cron job - scheduled to run immediately (next_run: {two_minutes_ago.strftime('%Y-%m-%d %H:%M:%S')})")
|
|
882
|
+
else:
|
|
883
|
+
logger.debug("populate_games cron job already exists, skipping creation.")
|
|
884
|
+
|
|
703
885
|
migrate_timestamp()
|
|
886
|
+
migrate_obs_scene_name()
|
|
887
|
+
# migrate_cron_timestamps() # Disabled - user will manually clean up data
|
|
888
|
+
migrate_jiten_cron_job()
|
|
889
|
+
migrate_populate_games_cron_job() # Run BEFORE daily_rollup to ensure games exist
|
|
890
|
+
migrate_daily_rollup_cron_job()
|
|
704
891
|
|
|
705
892
|
check_and_run_migrations()
|
|
706
893
|
|
GameSentenceMiner/util/ffmpeg.py
CHANGED
|
@@ -228,9 +228,19 @@ def get_screenshot(video_file, screenshot_timing, try_selector=False):
|
|
|
228
228
|
output_image = make_unique_file_name(os.path.join(
|
|
229
229
|
get_temporary_directory(), f"{obs.get_current_game(sanitize=True)}.{get_config().screenshot.extension}"))
|
|
230
230
|
|
|
231
|
+
pre_input_args = []
|
|
232
|
+
pre_input_args_string = ""
|
|
233
|
+
if get_config().screenshot.custom_ffmpeg_settings:
|
|
234
|
+
if '-hwaccel' in get_config().screenshot.custom_ffmpeg_settings:
|
|
235
|
+
hwaccel_args = get_config().screenshot.custom_ffmpeg_settings.split(" ")
|
|
236
|
+
hwaccel_index = hwaccel_args.index('-hwaccel')
|
|
237
|
+
pre_input_args.extend(hwaccel_args[hwaccel_index:hwaccel_index + 2])
|
|
238
|
+
pre_input_args_string = " ".join(pre_input_args)
|
|
239
|
+
|
|
231
240
|
# Base command for extracting the frame
|
|
232
241
|
ffmpeg_command = ffmpeg_base_command_list + [
|
|
233
242
|
"-ss", f"{screenshot_timing}",
|
|
243
|
+
] + pre_input_args + [
|
|
234
244
|
"-i", f"{video_file}",
|
|
235
245
|
"-vframes", "1" # Extract only one frame
|
|
236
246
|
]
|
|
@@ -252,7 +262,7 @@ def get_screenshot(video_file, screenshot_timing, try_selector=False):
|
|
|
252
262
|
ffmpeg_command.extend(["-vf", ",".join(video_filters)])
|
|
253
263
|
|
|
254
264
|
if get_config().screenshot.custom_ffmpeg_settings:
|
|
255
|
-
ffmpeg_command.extend(get_config().screenshot.custom_ffmpeg_settings.replace("\"", "").
|
|
265
|
+
ffmpeg_command.extend(get_config().screenshot.custom_ffmpeg_settings.replace("\"", "").replace(pre_input_args_string, "").split())
|
|
256
266
|
else:
|
|
257
267
|
# Ensure quality settings are strings
|
|
258
268
|
ffmpeg_command.extend(["-compression_level", "6", "-q:v", str(get_config().screenshot.quality)])
|
|
@@ -598,14 +608,23 @@ def get_screenshot_time(video_path, game_line, default_beginning=False, vad_resu
|
|
|
598
608
|
def process_image(image_file):
|
|
599
609
|
output_image = make_unique_file_name(
|
|
600
610
|
os.path.join(get_temporary_directory(), f"{obs.get_current_game(sanitize=True)}.{get_config().screenshot.extension}"))
|
|
611
|
+
|
|
612
|
+
pre_input_args = []
|
|
613
|
+
pre_input_args_string = ""
|
|
614
|
+
if get_config().screenshot.custom_ffmpeg_settings:
|
|
615
|
+
if '-hwaccel' in get_config().screenshot.custom_ffmpeg_settings:
|
|
616
|
+
hwaccel_args = get_config().screenshot.custom_ffmpeg_settings.split()
|
|
617
|
+
hwaccel_index = hwaccel_args.index('-hwaccel')
|
|
618
|
+
pre_input_args.extend(hwaccel_args[hwaccel_index:hwaccel_index + 2])
|
|
619
|
+
pre_input_args_string = " ".join(pre_input_args)
|
|
601
620
|
|
|
602
621
|
# FFmpeg command to process the input image
|
|
603
|
-
ffmpeg_command = ffmpeg_base_command_list + [
|
|
622
|
+
ffmpeg_command = ffmpeg_base_command_list + pre_input_args + [
|
|
604
623
|
"-i", image_file
|
|
605
624
|
]
|
|
606
625
|
|
|
607
626
|
if get_config().screenshot.custom_ffmpeg_settings:
|
|
608
|
-
ffmpeg_command.extend(get_config().screenshot.custom_ffmpeg_settings.
|
|
627
|
+
ffmpeg_command.extend(get_config().screenshot.custom_ffmpeg_settings.replace(pre_input_args_string, "").split())
|
|
609
628
|
else:
|
|
610
629
|
ffmpeg_command.extend(["-compression_level", "6", "-q:v", get_config().screenshot.quality])
|
|
611
630
|
|
|
@@ -769,7 +788,7 @@ def reencode_file_with_user_config(input_file, final_output_audio, user_ffmpeg_o
|
|
|
769
788
|
command = ffmpeg_base_command_list + [
|
|
770
789
|
"-i", input_file,
|
|
771
790
|
"-map", "0:a"
|
|
772
|
-
] + user_ffmpeg_options.replace("\"", "").split(
|
|
791
|
+
] + user_ffmpeg_options.replace("\"", "").split() + [
|
|
773
792
|
temp_file
|
|
774
793
|
]
|
|
775
794
|
|