GameSentenceMiner 2.16.6__py3-none-any.whl → 2.16.8__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.

@@ -366,6 +366,7 @@ class ConfigApp:
366
366
  self.language_value = tk.StringVar(value=self.settings.vad.language)
367
367
  self.cut_and_splice_segments_value = tk.BooleanVar(value=self.settings.vad.cut_and_splice_segments)
368
368
  self.splice_padding_value = tk.StringVar(value=str(self.settings.vad.splice_padding) if self.settings.vad.splice_padding else "")
369
+ self.use_vad_filter_for_whisper_value = tk.BooleanVar(value=self.settings.vad.use_vad_filter_for_whisper)
369
370
 
370
371
  # Advanced Settings
371
372
  self.audio_player_path_value = tk.StringVar(value=self.settings.advanced.audio_player_path)
@@ -598,6 +599,7 @@ class ConfigApp:
598
599
  cut_and_splice_segments=self.cut_and_splice_segments_value.get(),
599
600
  splice_padding=float(self.splice_padding_value.get()) if self.splice_padding_value.get() else 0.0,
600
601
  use_cpu_for_inference=self.use_cpu_for_inference_value.get(),
602
+ use_vad_filter_for_whisper=self.use_vad_filter_for_whisper_value.get(),
601
603
  ),
602
604
  advanced=Advanced(
603
605
  audio_player_path=self.audio_player_path_value.get(),
@@ -1150,6 +1152,12 @@ class ConfigApp:
1150
1152
  ttk.Checkbutton(vad_frame, variable=self.use_cpu_for_inference_value, bootstyle="round-toggle").grid(row=self.current_row, column=1, sticky='W', pady=2)
1151
1153
  self.current_row += 1
1152
1154
 
1155
+ # TODO Add Localization
1156
+ use_vad_filter_for_whisper_i18n = vad_i18n.get('use_vad_filter_for_whisper', {})
1157
+ HoverInfoLabelWidget(vad_frame, text=use_vad_filter_for_whisper_i18n.get('label', 'Use VAD Filter for Whisper'), tooltip=use_vad_filter_for_whisper_i18n.get('tooltip', 'Uses Silero to Filter out Non-Voiced Segments before Transcribing with Whisper.'), row=self.current_row, column=0)
1158
+ ttk.Checkbutton(vad_frame, variable=self.use_vad_filter_for_whisper_value, bootstyle="round-toggle").grid(row=self.current_row, column=1, sticky='W', pady=2)
1159
+ self.current_row += 1
1160
+
1153
1161
  # Add Reset Button
1154
1162
  self.add_reset_button(vad_frame, "vad", self.current_row, column=0, recreate_tab=self.create_vad_tab)
1155
1163
 
@@ -561,6 +561,7 @@ class VAD:
561
561
  cut_and_splice_segments: bool = False
562
562
  splice_padding: float = 0.1
563
563
  use_cpu_for_inference: bool = False
564
+ use_vad_filter_for_whisper: bool = True
564
565
 
565
566
  def is_silero(self):
566
567
  return self.selected_vad_model == SILERO or self.backup_vad_model == SILERO
@@ -1,16 +1,22 @@
1
1
 
2
2
 
3
+ from datetime import datetime
3
4
  import json
4
5
  import os
6
+ import random
5
7
  import shutil
6
8
  import sqlite3
7
9
  from sys import platform
8
10
  import time
9
11
  from typing import Any, Dict, List, Optional, Tuple, Union, Type, TypeVar
10
12
  import threading
13
+ import uuid
14
+
15
+ import pytz
16
+ from datetime import timedelta
11
17
 
12
18
  from GameSentenceMiner.util.text_log import GameLine
13
- from GameSentenceMiner.util.configuration import logger, is_dev
19
+ from GameSentenceMiner.util.configuration import get_stats_config, logger, is_dev
14
20
  import gzip
15
21
 
16
22
 
@@ -23,6 +29,14 @@ class SQLiteDB:
23
29
  def __init__(self, db_path: str):
24
30
  self.db_path = db_path
25
31
  self._lock = threading.Lock()
32
+
33
+ def backup(self, backup_path: str):
34
+ """ Create a backup of the database using built in SQLite backup API. """
35
+ with self._lock, sqlite3.connect(self.db_path, check_same_thread=False) as conn:
36
+ with sqlite3.connect(backup_path, check_same_thread=False) as backup_conn:
37
+ conn.backup(backup_conn)
38
+ if is_dev:
39
+ logger.debug(f"Database backed up to {backup_path}")
26
40
 
27
41
  def execute(self, query: str, params: Union[Tuple, Dict] = (), commit: bool = False) -> sqlite3.Cursor:
28
42
  with self._lock, sqlite3.connect(self.db_path, check_same_thread=False) as conn:
@@ -341,7 +355,7 @@ class GameLinesTable(SQLiteDBTable):
341
355
  self.game_name = game_name
342
356
  self.line_text = line_text
343
357
  self.context = context
344
- self.timestamp = timestamp if timestamp is not None else time.time()
358
+ self.timestamp = timestamp if timestamp is not None else datetime.now().timestamp()
345
359
  self.screenshot_in_anki = screenshot_in_anki if screenshot_in_anki is not None else ''
346
360
  self.audio_in_anki = audio_in_anki if audio_in_anki is not None else ''
347
361
  self.screenshot_path = screenshot_path if screenshot_path is not None else ''
@@ -388,6 +402,20 @@ class GameLinesTable(SQLiteDBTable):
388
402
  # logger.info("Adding GameLine to DB: %s", new_line)
389
403
  new_line.add()
390
404
  return new_line
405
+
406
+ @classmethod
407
+ def add_lines(cls, gamelines: List[GameLine]):
408
+ new_lines = [cls(id=gl.id, game_name=gl.scene,
409
+ line_text=gl.text, timestamp=gl.time.timestamp()) for gl in gamelines]
410
+ # logger.info("Adding %d GameLines to DB", len(new_lines))
411
+ params = [(line.id, line.game_name, line.line_text, line.timestamp, line.screenshot_in_anki,
412
+ line.audio_in_anki, line.screenshot_path, line.audio_path, line.replay_path, line.translation)
413
+ for line in new_lines]
414
+ cls._db.executemany(
415
+ f"INSERT INTO {cls._table} (id, game_name, line_text, timestamp, screenshot_in_anki, audio_in_anki, screenshot_path, audio_path, replay_path, translation) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
416
+ params,
417
+ commit=True
418
+ )
391
419
 
392
420
 
393
421
  def get_db_directory():
@@ -441,3 +469,56 @@ for cls in [AIModelsTable, GameLinesTable]:
441
469
  # Uncomment to start fresh every time
442
470
  # cls.drop()
443
471
  # cls.set_db(gsm_db) # --- IGNORE ---
472
+
473
+ # import random
474
+ # import uuid
475
+ # from datetime import datetime
476
+ # from GameSentenceMiner.util.text_log import GameLine
477
+ # from GameSentenceMiner.util.db import GameLinesTable
478
+
479
+ # List of common Japanese characters (kanji, hiragana, katakana)
480
+ # japanese_chars = (
481
+ # "あいうえおかきくけこさしすせそたちつてとなにぬねのはひふへほまみむめもやゆよらりるれろわをん"
482
+ # "アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワヲン"
483
+ # "亜唖娃阿哀愛挨悪握圧扱宛嵐安暗案闇以衣位囲医依委威為胃尉異移維緯"
484
+ # # ... (add more kanji for more variety)
485
+ # )
486
+
487
+ # def random_japanese_text(length):
488
+ # return ''.join(random.choices(japanese_chars, k=length))
489
+
490
+ # batch_size = 1000
491
+ # lines_batch = []
492
+
493
+ # def random_datetime(start_year=2024, end_year=2026):
494
+ # start = datetime(start_year, 1, 1, 0, 0, 0)
495
+ # end = datetime(end_year, 12, 31, 23, 59, 59)
496
+ # delta = end - start
497
+ # random_seconds = random.randint(0, int(delta.total_seconds()))
498
+ # return start + timedelta(seconds=random_seconds)
499
+
500
+ # from datetime import timedelta
501
+
502
+ # for i in range(500000): # Adjust for desired number of lines
503
+ # line_text = random_japanese_text(random.randint(25, 40))
504
+ # lines_batch.append(GameLine(
505
+ # id=str(uuid.uuid1()),
506
+ # text=line_text,
507
+ # time=random_datetime(),
508
+ # prev=None,
509
+ # next=None,
510
+ # index=i,
511
+ # scene="RandomScene"
512
+ # ))
513
+
514
+ # if len(lines_batch) >= batch_size:
515
+ # GameLinesTable.add_lines(lines_batch)
516
+ # lines_batch = []
517
+ # if i % 1000 == 0:
518
+ # print(f"Inserted {i} lines...")
519
+
520
+ # # Insert any remaining lines
521
+ # if lines_batch:
522
+ # GameLinesTable.add_lines(lines_batch)
523
+
524
+ print("Done populating GameLinesDB with random Japanese text.")
@@ -495,6 +495,7 @@ def get_video_timings(video_path, game_line, anki_card_creation_time=None):
495
495
  total_seconds = file_length - time_delta.total_seconds()
496
496
  total_seconds_after_offset = total_seconds + get_config().audio.beginning_offset
497
497
  if total_seconds < 0 or total_seconds >= file_length:
498
+ logger.debug(f"get_video_timings: file_mod_time={file_mod_time}, file_length={file_length}, time_delta={time_delta}, total_seconds={total_seconds}, total_seconds_after_offset={total_seconds_after_offset}")
498
499
  logger.error("Line mined is outside of the replay buffer! Defaulting to the last 30 seconds of the replay buffer.")
499
500
  logger.info("Recommend either increasing replay buffer length in OBS Settings or mining faster.")
500
501
  return max(file_length - 30, 0), 0, max(file_length - 30, 0), file_length
GameSentenceMiner/vad.py CHANGED
@@ -185,7 +185,7 @@ class WhisperVADProcessor(VADProcessor):
185
185
  # Transcribe the audio using Whisper
186
186
  with warnings.catch_warnings():
187
187
  warnings.simplefilter("ignore")
188
- result: WhisperResult = self.vad_model.transcribe(temp_wav, vad=True, language=get_config().vad.language,
188
+ result: WhisperResult = self.vad_model.transcribe(temp_wav, vad=True, language=get_config().vad.language, vad_filter=get_config().vad.use_vad_filter_for_whisper,
189
189
  temperature=0.0)
190
190
  voice_activity = []
191
191
 
@@ -406,6 +406,7 @@ def test_vad_processors():
406
406
  get_config().vad.cut_and_splice_segments = False
407
407
  get_config().vad.trim_beginning = True
408
408
  get_config().vad.add_audio_on_no_results = True
409
+ get_config().vad.use_vad_filter_for_whisper = False
409
410
  for processor, out_name in processors:
410
411
  logger.info("Testing Trim Audio with " + processor.vad_system_name)
411
412
  out_path = os.path.join(output_dir, out_name.replace("after_splice_", "after_trim_"))
@@ -12,6 +12,7 @@ import regex
12
12
 
13
13
  from GameSentenceMiner.util.db import GameLinesTable
14
14
  from GameSentenceMiner.util.configuration import get_stats_config, logger, get_config, save_current_config, save_stats_config
15
+ from GameSentenceMiner.util.text_log import GameLine
15
16
  from GameSentenceMiner.web.stats import (
16
17
  calculate_kanji_frequency, calculate_heatmap_data, calculate_total_chars_per_game,
17
18
  calculate_reading_time_per_game, calculate_reading_speed_per_game,
@@ -304,7 +305,7 @@ def register_database_api_routes(app):
304
305
  'streak_requirement_hours': config.streak_requirement_hours,
305
306
  'reading_hours_target': config.reading_hours_target,
306
307
  'character_count_target': config.character_count_target,
307
- 'games_target': config.games_target
308
+ 'games_target': config.games_target,
308
309
  }), 200
309
310
  except Exception as e:
310
311
  logger.error(f"Error getting settings: {e}")
@@ -806,6 +807,8 @@ def register_database_api_routes(app):
806
807
  daily_data = defaultdict(lambda: defaultdict(lambda: {'lines': 0, 'chars': 0}))
807
808
 
808
809
 
810
+
811
+
809
812
  # start_time = time.perf_counter()
810
813
  # for line in all_lines:
811
814
  # day_str = datetime.date.fromtimestamp(float(line.timestamp)).strftime('%Y-%m-%d')
@@ -975,24 +978,31 @@ def register_database_api_routes(app):
975
978
  csv_reader = csv.DictReader(file_io, quoting=csv.QUOTE_MINIMAL, skipinitialspace=True)
976
979
 
977
980
  # Process CSV rows
978
- imported_lines = []
979
981
  games_set = set()
980
982
  errors = []
981
- seen_uuids = set() # Track UUIDs within this import batch
982
983
 
984
+ all_lines = GameLinesTable.all()
985
+ existing_uuids = {line.id for line in all_lines}
986
+ batch_size = 1000 # For logging progress
987
+ batch_insert = []
988
+ imported_count = 0
989
+
990
+ def get_line_hash(uuid: str, line_text: str) -> str:
991
+ return uuid + '|' + line_text.strip()
992
+
983
993
  for row_num, row in enumerate(csv_reader):
984
994
  try:
985
995
  # Extract and validate required fields
986
- uuid = row.get('uuid', '').strip()
987
- name = row.get('name', '').strip()
996
+ game_uuid = row.get('uuid', '').strip()
997
+ game_name = row.get('name', '').strip()
988
998
  line = row.get('line', '').strip()
989
999
  time_str = row.get('time', '').strip()
990
1000
 
991
1001
  # Validate required fields
992
- if not uuid:
1002
+ if not game_uuid:
993
1003
  errors.append(f"Row {row_num}: Missing UUID")
994
1004
  continue
995
- if not name:
1005
+ if not game_name:
996
1006
  errors.append(f"Row {row_num}: Missing name")
997
1007
  continue
998
1008
  if not line:
@@ -1002,12 +1012,13 @@ def register_database_api_routes(app):
1002
1012
  errors.append(f"Row {row_num}: Missing time")
1003
1013
  continue
1004
1014
 
1005
- # Check for duplicates within this import batch
1006
- if uuid in seen_uuids:
1007
- logger.info(f"Skipping duplicate UUID within import batch: {uuid}")
1008
- continue
1009
- seen_uuids.add(uuid)
1015
+ line_hash = get_line_hash(game_uuid, line)
1010
1016
 
1017
+ # Check if this line already exists in database
1018
+ if line_hash in existing_uuids:
1019
+ logger.info(f"Skipping duplicate UUID already in database: {line_hash}")
1020
+ continue
1021
+
1011
1022
  # Convert time to timestamp
1012
1023
  try:
1013
1024
  timestamp = float(time_str)
@@ -1018,37 +1029,52 @@ def register_database_api_routes(app):
1018
1029
  # Clean up line text (remove extra whitespace and newlines)
1019
1030
  line_text = line.strip()
1020
1031
 
1021
- # Check if this UUID already exists in database
1022
- existing_line = GameLinesTable.get(uuid)
1023
- if existing_line:
1024
- logger.info(f"Skipping duplicate UUID already in database: {uuid}")
1025
- continue
1026
-
1027
1032
  # Create GameLinesTable entry
1028
- game_line = GameLinesTable(
1029
- id=uuid,
1030
- game_name=name,
1031
- line_text=line_text,
1032
- timestamp=timestamp
1033
- )
1033
+ # Convert timestamp float to datetime object
1034
+ dt = datetime.datetime.fromtimestamp(timestamp)
1035
+ batch_insert.append(GameLine(
1036
+ id=line_hash,
1037
+ text=line_text,
1038
+ scene=game_name,
1039
+ time=dt,
1040
+ prev=None,
1041
+ next=None,
1042
+ index=0,
1043
+ ))
1044
+
1045
+ logger.info(f"Batch insert size: {len(batch_insert)}")
1046
+
1047
+ existing_uuids.add(line_hash) # Add to existing to prevent duplicates in same import
1034
1048
 
1035
- imported_lines.append(game_line)
1036
- games_set.add(name)
1049
+ if len(batch_insert) >= batch_size:
1050
+ logger.info(f"Importing batch of {len(batch_insert)} lines...")
1051
+ GameLinesTable.add_lines(batch_insert)
1052
+ imported_count += len(batch_insert)
1053
+ batch_insert = []
1054
+ games_set.add(game_name)
1037
1055
 
1038
1056
  except Exception as e:
1057
+ logger.error(f"Error processing row {row_num}: {e}")
1039
1058
  errors.append(f"Row {row_num}: Error processing row - {str(e)}")
1040
1059
  continue
1060
+
1061
+ # Insert the rest of the batch
1062
+ if batch_insert:
1063
+ logger.info(f"Importing final batch of {len(batch_insert)} lines...")
1064
+ GameLinesTable.add_lines(batch_insert)
1065
+ imported_count += len(batch_insert)
1066
+ batch_insert = []
1041
1067
 
1042
- # Import lines into database
1043
- imported_count = 0
1044
- for game_line in imported_lines:
1045
- try:
1046
- game_line.add()
1047
- imported_count += 1
1048
- except Exception as e:
1049
- logger.error(f"Failed to import line {game_line.id}: {e}")
1050
- errors.append(f"Failed to import line {game_line.id}: {str(e)}")
1051
-
1068
+ # # Import lines into database
1069
+ # imported_count = 0
1070
+ # for game_line in imported_lines:
1071
+ # try:
1072
+ # game_line.add()
1073
+ # imported_count += 1
1074
+ # except Exception as e:
1075
+ # logger.error(f"Failed to import line {game_line.id}: {e}")
1076
+ # errors.append(f"Failed to import line {game_line.id}: {str(e)}")
1077
+
1052
1078
  # Prepare response
1053
1079
  response_data = {
1054
1080
  'message': f'Successfully imported {imported_count} lines from {len(games_set)} games',
@@ -1063,6 +1089,8 @@ def register_database_api_routes(app):
1063
1089
 
1064
1090
  logger.info(f"ExStatic import completed: {imported_count} lines from {len(games_set)} games")
1065
1091
 
1092
+ logger.info(f"Import response: {response_data}")
1093
+
1066
1094
  return jsonify(response_data), 200
1067
1095
 
1068
1096
  except csv.Error as e:
@@ -186,7 +186,7 @@ class SentenceSearchApp {
186
186
 
187
187
  // Format timestamp to ISO format
188
188
  const date = new Date(result.timestamp * 1000);
189
- const formattedDate = date.toISOString().split('T')[0] + ' ' + date.toTimeString().split(' ')[0];
189
+ const formattedDate = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${date.toTimeString().split(' ')[0]}`;
190
190
 
191
191
  div.innerHTML = `
192
192
  <div class="result-sentence">${highlightedText}</div>
@@ -128,7 +128,7 @@ document.addEventListener('DOMContentLoaded', function () {
128
128
  for (let day = 0; day < 7; day++) {
129
129
  const date = grid[day][week];
130
130
  if (date) {
131
- const dateStr = date.toISOString().split('T')[0];
131
+ const dateStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
132
132
  const activity = yearData[dateStr] || 0;
133
133
  dates.push({ date: dateStr, activity: activity });
134
134
  }
@@ -154,7 +154,8 @@ document.addEventListener('DOMContentLoaded', function () {
154
154
  }
155
155
 
156
156
  // Calculate current streak from today backwards
157
- const today = new Date().toISOString().split('T')[0];
157
+ const date = new Date();
158
+ const today = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
158
159
 
159
160
  // Find today's index or the most recent date before today
160
161
  let todayIndex = -1;
@@ -182,7 +183,10 @@ document.addEventListener('DOMContentLoaded', function () {
182
183
  // Group timestamps by day for this year
183
184
  const dailyTimestamps = {};
184
185
  for (const line of allLinesForYear) {
185
- const dateStr = new Date(parseFloat(line.timestamp) * 1000).toISOString().split('T')[0];
186
+ const ts = parseFloat(line.timestamp);
187
+ if (isNaN(ts)) continue;
188
+ const dateObj = new Date(ts * 1000);
189
+ const dateStr = `${dateObj.getFullYear()}-${String(dateObj.getMonth() + 1).padStart(2, '0')}-${String(dateObj.getDate()).padStart(2, '0')}`;
186
190
  if (!dailyTimestamps[dateStr]) {
187
191
  dailyTimestamps[dateStr] = [];
188
192
  }
@@ -326,7 +330,7 @@ document.addEventListener('DOMContentLoaded', function () {
326
330
 
327
331
  const date = grid[day][week];
328
332
  if (date) {
329
- const dateStr = date.toISOString().split('T')[0];
333
+ const dateStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
330
334
  const activity = yearData[dateStr] || 0;
331
335
 
332
336
  if (activity > 0 && maxActivity > 0) {
@@ -881,11 +885,14 @@ document.addEventListener('DOMContentLoaded', function () {
881
885
  // Group by day and calculate reading time using AFK timer logic
882
886
  const dailyTimestamps = {};
883
887
  for (const line of recentData) {
884
- const dateStr = new Date(line.timestamp * 1000).toISOString().split('T')[0];
888
+ const ts = parseFloat(line.timestamp);
889
+ if (isNaN(ts)) continue;
890
+ const dateObj = new Date(ts * 1000);
891
+ const dateStr = `${dateObj.getFullYear()}-${String(dateObj.getMonth() + 1).padStart(2, '0')}-${String(dateObj.getDate()).padStart(2, '0')}`;
885
892
  if (!dailyTimestamps[dateStr]) {
886
893
  dailyTimestamps[dateStr] = [];
887
894
  }
888
- dailyTimestamps[dateStr].push(line.timestamp);
895
+ dailyTimestamps[dateStr].push(ts);
889
896
  }
890
897
 
891
898
  for (const [dateStr, timestamps] of Object.entries(dailyTimestamps)) {
@@ -906,14 +913,20 @@ document.addEventListener('DOMContentLoaded', function () {
906
913
  } else if (metricType === 'characters') {
907
914
  // Group by day and sum characters
908
915
  for (const line of recentData) {
909
- const dateStr = new Date(line.timestamp * 1000).toISOString().split('T')[0];
916
+ const ts = parseFloat(line.timestamp);
917
+ if (isNaN(ts)) continue;
918
+ const dateObj = new Date(ts * 1000);
919
+ const dateStr = `${dateObj.getFullYear()}-${String(dateObj.getMonth() + 1).padStart(2, '0')}-${String(dateObj.getDate()).padStart(2, '0')}`;
910
920
  dailyTotals[dateStr] = (dailyTotals[dateStr] || 0) + (line.characters || 0);
911
921
  }
912
922
  } else if (metricType === 'games') {
913
923
  // Group by day and count unique games
914
924
  const dailyGames = {};
915
925
  for (const line of recentData) {
916
- const dateStr = new Date(line.timestamp * 1000).toISOString().split('T')[0];
926
+ const ts = parseFloat(line.timestamp);
927
+ if (isNaN(ts)) continue;
928
+ const dateObj = new Date(ts * 1000);
929
+ const dateStr = `${dateObj.getFullYear()}-${String(dateObj.getMonth() + 1).padStart(2, '0')}-${String(dateObj.getDate()).padStart(2, '0')}`;
917
930
  if (!dailyGames[dateStr]) {
918
931
  dailyGames[dateStr] = new Set();
919
932
  }
@@ -1141,14 +1154,19 @@ document.addEventListener('DOMContentLoaded', function () {
1141
1154
  function loadDashboardData(data = null) {
1142
1155
  function updateTodayOverview(allLinesData) {
1143
1156
  // Get today's date string (YYYY-MM-DD)
1157
+ // Get today's date string (YYYY-MM-DD), timezone aware (local time)
1144
1158
  const today = new Date();
1145
- const todayStr = today.toISOString().split('T')[0];
1159
+ const pad = n => n.toString().padStart(2, '0');
1160
+ const todayStr = `${today.getFullYear()}-${pad(today.getMonth() + 1)}-${pad(today.getDate())}`;
1146
1161
  document.getElementById('todayDate').textContent = todayStr;
1147
1162
 
1148
1163
  // Filter lines for today
1149
1164
  const todayLines = (allLinesData || []).filter(line => {
1150
1165
  if (!line.timestamp) return false;
1151
- const lineDate = new Date(parseFloat(line.timestamp) * 1000).toISOString().split('T')[0];
1166
+ const ts = parseFloat(line.timestamp);
1167
+ if (isNaN(ts)) return false;
1168
+ const dateObj = new Date(ts * 1000);
1169
+ const lineDate = `${dateObj.getFullYear()}-${pad(dateObj.getMonth() + 1)}-${pad(dateObj.getDate())}`;
1152
1170
  return lineDate === todayStr;
1153
1171
  });
1154
1172
 
@@ -1195,7 +1213,13 @@ document.addEventListener('DOMContentLoaded', function () {
1195
1213
  .map(l => parseFloat(l.timestamp))
1196
1214
  .filter(ts => !isNaN(ts))
1197
1215
  .sort((a, b) => a - b);
1198
- const afkTimerSeconds = 120;
1216
+ // Get AFK timer from settings modal if available
1217
+ let afkTimerSeconds = 120; // default
1218
+ const afkTimerInput = document.getElementById('afkTimer');
1219
+ if (afkTimerInput && afkTimerInput.value) {
1220
+ const parsed = parseInt(afkTimerInput.value, 10);
1221
+ if (!isNaN(parsed) && parsed > 0) afkTimerSeconds = parsed;
1222
+ }
1199
1223
  if (timestamps.length >= 2) {
1200
1224
  for (let i = 1; i < timestamps.length; i++) {
1201
1225
  const gap = timestamps[i] - timestamps[i-1];