Ethscriptions commited on
Commit
922a0dc
·
verified ·
1 Parent(s): 40f2345

Update pages/monitor_playback.py

Browse files
Files changed (1) hide show
  1. pages/monitor_playback.py +95 -83
pages/monitor_playback.py CHANGED
@@ -11,7 +11,7 @@ from collections import deque
11
  from dotenv import load_dotenv
12
 
13
  # --- 0. 页面配置 ---
14
- st.set_page_config(page_title="影城空场防空转监控 Pro", page_icon="🔋", layout="wide")
15
 
16
  try:
17
  from streamlit_autorefresh import st_autorefresh
@@ -21,12 +21,12 @@ except ImportError:
21
  # --- 1. 配置与常量 ---
22
  load_dotenv()
23
 
24
- PUSHOVER_TOKEN = os.getenv("PUSHOVER_TOKEN", "")
25
- PUSHOVER_USER = os.getenv("PUSHOVER_USER", "")
26
  # 使用 NTFY 配置
27
- NTFY_TOPIC = os.getenv("ntfy_code", "gzhdhdtz")
28
 
29
- CINEMA_ID = os.getenv("CINEMA_ID", "44001291")
30
  TMS_APP_SECRET = os.getenv("TMS_APP_SECRET")
31
  TMS_TICKET = os.getenv("TMS_TICKET")
32
  TMS_X_SESSION_ID = os.getenv("TMS_X_SESSION_ID")
@@ -46,6 +46,7 @@ TOKEN_FILE = os.path.join(ROOT_DIR, 'token_data.json')
46
 
47
  class DailyStats:
48
  """每日运行数据统计"""
 
49
  def __init__(self):
50
  self.reset()
51
  self.yesterday_stats = {
@@ -57,10 +58,10 @@ class DailyStats:
57
  }
58
 
59
  def reset(self):
60
- self.zero_sessions = 0 # 0票总场次
61
- self.triggers = 0 # 触发通知次数
62
- self.notify_fails = 0 # 发送通知失败次数
63
- self.api_fails = 0 # API获取失败次数
64
 
65
  def snapshot_as_yesterday(self, date_str):
66
  self.yesterday_stats = {
@@ -72,6 +73,7 @@ class DailyStats:
72
  }
73
  self.reset()
74
 
 
75
  # 全局统计实例
76
  daily_stats = DailyStats()
77
 
@@ -80,12 +82,14 @@ def get_beijing_now():
80
  utc_now = datetime.now(timezone.utc)
81
  return utc_now.astimezone(timezone(timedelta(hours=8))).replace(tzinfo=None)
82
 
 
83
  def get_business_date():
84
  now = get_beijing_now()
85
  if now.time() < dt_time(6, 0):
86
  return (now - timedelta(days=1)).strftime("%Y-%m-%d")
87
  return now.strftime("%Y-%m-%d")
88
 
 
89
  def parse_show_datetime(date_str, time_str):
90
  try:
91
  show_t = datetime.strptime(time_str, "%H:%M").time()
@@ -96,11 +100,13 @@ def parse_show_datetime(date_str, time_str):
96
  except:
97
  return None
98
 
 
99
  def extract_hall_number_raw(hall_name):
100
  """仅提取数字ID,用于API映射"""
101
  match = re.search(r'(\d+)', str(hall_name))
102
  return match.group(1) if match else str(hall_name)
103
 
 
104
  def format_hall_name(hall_name):
105
  """格式化影厅名称:【和成天下1号厅】 -> 1号厅"""
106
  match = re.search(r'(\d+)号', str(hall_name))
@@ -154,7 +160,7 @@ class NotificationManager:
154
  daily_stats.notify_fails += 1
155
 
156
  self.ntfy_queue.task_done()
157
-
158
  except Exception as e:
159
  print(f"❌ NTFY 线程异常: {e}")
160
  time.sleep(5)
@@ -168,7 +174,7 @@ class NotificationManager:
168
  def send_daily_report(self, today_date):
169
  """发送每日统计报告"""
170
  y_stats = daily_stats.yesterday_stats
171
-
172
  title = "🟢 影城“空转”检查服务就绪"
173
  msg = (
174
  f"{today_date},今日排片数据已加载,开始智能检查。\n\n"
@@ -177,9 +183,8 @@ class NotificationManager:
177
  f"触发通知次数:{y_stats['triggers']}\n"
178
  f"发送通知失败次数:{y_stats['notify_fails']}\n"
179
  f"API获取失败次数:{y_stats['api_fails']}\n"
180
- f"(包括排片数据API和获取TMS数据API)"
181
  )
182
-
183
  self.send_pushover(title, msg, priority=0)
184
  self.ntfy_queue.put((title, msg, 0))
185
 
@@ -198,7 +203,8 @@ class TicketAPIManager:
198
  try:
199
  with open(TOKEN_FILE, 'r', encoding='utf-8') as f:
200
  return json.load(f).get('token')
201
- except: pass
 
202
  return None
203
 
204
  def login(self):
@@ -216,7 +222,8 @@ class TicketAPIManager:
216
  try:
217
  session = requests.Session()
218
  login_url = 'https://app.bi.piao51.cn/cinema-app/credential/login.action'
219
- login_data = {'username': username, 'password': password, 'type': '1', 'resCode': res_code, 'deviceid': device_id, 'dtype': 'ios'}
 
220
  session.post(login_url, data=login_data, timeout=15)
221
  resp = session.get('https://app.bi.piao51.cn/cinema-app/security/logined.action', timeout=10)
222
  info = resp.json()
@@ -234,7 +241,7 @@ class TicketAPIManager:
234
  token = self.load_token()
235
  if not token:
236
  token = self.login()
237
- if not token:
238
  daily_stats.api_fails += 1
239
  return None
240
 
@@ -268,7 +275,7 @@ class TMSAPIManager:
268
  def get_token(self):
269
  if self.auth_token and (time.time() - self.last_token_time < 1800):
270
  return self.auth_token
271
-
272
  if not all([TMS_APP_SECRET, TMS_TICKET]): return None
273
 
274
  url = f'http://oa.hengdianfilm.com:7080/cinema-api/admin/generateToken?token=hd&murl=?token=hd&murl=ticket={TMS_TICKET}'
@@ -282,7 +289,8 @@ class TMSAPIManager:
282
  self.auth_token = data['param']
283
  self.last_token_time = time.time()
284
  return self.auth_token
285
- except: pass
 
286
  daily_stats.api_fails += 1
287
  return None
288
 
@@ -296,7 +304,7 @@ class TMSAPIManager:
296
  url = 'https://tms.hengdianfilm.com/cinema-api/cinema/schedule/server/list'
297
  session_id = TMS_X_SESSION_ID or ''
298
  cookies = {'JSESSIONID': session_id}
299
-
300
  headers = {
301
  'accept': 'application/json, text/javascript, */*; q=0.01',
302
  'content-type': 'application/json; charset=UTF-8',
@@ -318,7 +326,8 @@ class TMSAPIManager:
318
  }
319
 
320
  try:
321
- res = requests.post(url, params=params, cookies=cookies, headers=headers, json=json_data, timeout=10, verify=False)
 
322
  data = res.json()
323
  if data.get("RSPCD") == "000000":
324
  return data.get("BODY", {}).get("LIST", [])
@@ -337,16 +346,16 @@ class PlaybackMonitor:
337
  self.status_text = "初始化中..."
338
  self.next_wakeup_str = "--:--:--"
339
  self.active_monitors = []
340
-
341
  self.cleared_sessions = set()
342
  self.processed_checks = set()
343
 
344
  self.ticket_api = TicketAPIManager(self.log)
345
  self.tms_api = TMSAPIManager(self.log)
346
-
347
  self.current_business_date = None
348
  self.daily_schedule_cache = None
349
-
350
  self.thread = threading.Thread(target=self._run_loop, daemon=True)
351
  self.thread.start()
352
 
@@ -365,32 +374,32 @@ class PlaybackMonitor:
365
  zero_count += 1
366
  start_str = item.get('showStartTime')
367
  if not start_str: continue
368
-
369
  show_dt = parse_show_datetime(business_date, start_str)
370
  if not show_dt: continue
371
-
372
- for i in range(3):
373
  check_time = show_dt + timedelta(minutes=i * 5)
374
  check_points.append({
375
- 'time': check_time,
376
- 'type': 'CHECK',
377
- 'check_index': i,
378
  'data': item
379
  })
380
-
381
  daily_stats.zero_sessions = zero_count
382
-
383
  check_points.sort(key=lambda x: x['time'])
384
  return check_points
385
 
386
  def _run_loop(self):
387
- self.log("🚀 智能防空转监控已启动 (09:00唤醒修复版)")
388
-
389
  while True:
390
  try:
391
  now = get_beijing_now()
392
  biz_date = get_business_date()
393
-
394
  # --- 初始化与日期变更 ---
395
  need_refresh = False
396
  if self.daily_schedule_cache is None:
@@ -400,7 +409,7 @@ class PlaybackMonitor:
400
  elif self.current_business_date != biz_date:
401
  if now.time() >= dt_time(9, 0):
402
  need_refresh = True
403
-
404
  daily_stats.snapshot_as_yesterday(self.current_business_date)
405
  self.cleared_sessions.clear()
406
  self.processed_checks.clear()
@@ -412,13 +421,13 @@ class PlaybackMonitor:
412
  self.daily_schedule_cache = schedule
413
  self.current_business_date = biz_date
414
  self.log(f"✅ 排片已更新,共 {len(schedule)} 场。")
415
-
416
  try:
417
  d_obj = datetime.strptime(biz_date, "%Y-%m-%d")
418
  date_cn = f"{d_obj.year}年{d_obj.month}月{d_obj.day}日"
419
  except:
420
  date_cn = biz_date
421
-
422
  notifier.send_daily_report(date_cn)
423
  else:
424
  self.log("⚠️ 获取排片失败,5分钟后重试")
@@ -428,59 +437,60 @@ class PlaybackMonitor:
428
 
429
  # --- 任务计算 ---
430
  check_points = self._get_target_check_points(self.daily_schedule_cache, biz_date)
431
-
432
  next_target = None
433
  for cp in check_points:
434
  hall_id = extract_hall_number_raw(cp['data']['hallName'])
435
  start_str = cp['data']['showStartTime']
436
  check_idx = cp['check_index']
437
  dedup_key = f"{biz_date}_{hall_id}_{start_str}_{check_idx}"
438
-
439
  if dedup_key in self.processed_checks:
440
  continue
441
-
442
  if cp['time'] > (now - timedelta(seconds=30)):
443
  next_target = cp
444
  break
445
-
446
  if next_target:
447
  target_time = next_target['time']
448
-
449
  # === 核心修复:09:00 拦截器 ===
450
  # 如果当前时间早于 09:00,且下一场电影晚于 09:00,
451
  # 必须在 09:00 醒来处理新的一天初始化,不能一觉睡过头。
452
  nine_am_today = datetime.combine(now.date(), dt_time(9, 0))
453
-
454
  force_wake_for_daily_reset = False
455
  if now < nine_am_today and target_time > nine_am_today:
456
  target_time = nine_am_today
457
  force_wake_for_daily_reset = True
458
-
459
  sleep_seconds = max(0, (target_time - now).total_seconds())
460
-
461
- self.status_text = f"💤 休眠中 (等待: {target_time.strftime('%H:%M')})"
462
  self.next_wakeup_str = target_time.strftime('%H:%M:%S')
463
-
464
  if force_wake_for_daily_reset:
465
  self.active_monitors = ["等待 09:00 日报刷新..."]
466
  else:
467
  raw_hall = next_target['data']['hallName']
468
  clean_hall = format_hall_name(raw_hall)
469
- self.active_monitors = [f"下个任务: {clean_hall} {next_target['data']['showStartTime']} (第{next_target['check_index']+1}次检查)"]
470
-
 
471
  self.log(f"💤 智能休眠 {int(sleep_seconds)}秒,将在 {self.next_wakeup_str} 唤醒...")
472
-
473
  time.sleep(sleep_seconds)
474
-
475
  # 如果是被强制唤醒来做日报刷新的,直接跳过后续的检查逻辑,
476
  # continue 会回到循环头部,触发 if now >= 9:00 的初始化逻辑。
477
  if force_wake_for_daily_reset:
478
  continue
479
-
480
  # --- 正常唤醒检查 ---
481
  self.status_text = "🔥 正在执行检查..."
482
  self.log("⏰ 唤醒!正在同步最新票务数据...")
483
-
484
  latest_schedule = self.ticket_api.fetch_schedule(biz_date)
485
  if latest_schedule:
486
  self.daily_schedule_cache = latest_schedule
@@ -492,23 +502,24 @@ class PlaybackMonitor:
492
  check_now = get_beijing_now()
493
  current_targets = []
494
  latest_check_points = self._get_target_check_points(latest_schedule, biz_date)
495
-
496
  for cp in latest_check_points:
497
  time_diff = abs((cp['time'] - check_now).total_seconds())
498
- if time_diff < 120:
499
  current_targets.append(cp)
500
 
501
  if current_targets:
502
  self._process_targets(current_targets, biz_date)
503
-
504
  time.sleep(5)
505
-
506
  else:
507
  self.status_text = "🌙 今日监控结束"
508
  self.active_monitors = []
509
- tmr_9am = datetime.combine(datetime.strptime(biz_date, "%Y-%m-%d").date() + timedelta(days=1), dt_time(9, 0))
 
510
  seconds_to_tmr = (tmr_9am - now).total_seconds()
511
-
512
  if seconds_to_tmr > 3600:
513
  self.log("暂无目标,休眠 1 小时...")
514
  time.sleep(3600)
@@ -526,11 +537,11 @@ class PlaybackMonitor:
526
  hall_id = extract_hall_number_raw(t['data']['hallName'])
527
  start_str = t['data']['showStartTime']
528
  check_idx = t['check_index']
529
-
530
  dedup_key = f"{biz_date}_{hall_id}_{start_str}_{check_idx}"
531
  if dedup_key in self.processed_checks:
532
  continue
533
-
534
  if hall_id not in halls_to_check:
535
  halls_to_check[hall_id] = []
536
  halls_to_check[hall_id].append(t)
@@ -539,9 +550,9 @@ class PlaybackMonitor:
539
 
540
  self.log(f"🔍 核查 {len(halls_to_check)} 个影厅 TMS 状态...")
541
  display_results = []
542
-
543
  for hall_id, target_list in halls_to_check.items():
544
-
545
  all_cleared = True
546
  for target in target_list:
547
  start_str = target['data'].get('showStartTime')
@@ -549,37 +560,37 @@ class PlaybackMonitor:
549
  if key not in self.cleared_sessions:
550
  all_cleared = False
551
  break
552
-
553
  if all_cleared:
554
  for target in target_list:
555
  dedup_key = f"{biz_date}_{hall_id}_{target['data']['showStartTime']}_{target['check_index']}"
556
  self.processed_checks.add(dedup_key)
557
-
558
  clean_name = format_hall_name(target_list[0]['data']['hallName'])
559
  display_results.append(f"{clean_name} ✅ [已跳过 (已确认撤场)]")
560
  continue
561
 
562
  tms_list = self.tms_api.fetch_hall_schedule_list(hall_id)
563
-
564
  for target in target_list:
565
  session = target['data']
566
  check_idx = target['check_index']
567
  hall_name_raw = session.get('hallName')
568
  movie_name = session.get('movieName')
569
- ticket_start_str = session.get('showStartTime')
570
  ticket_start_dt = parse_show_datetime(biz_date, ticket_start_str)
571
-
572
  unique_key = f"{biz_date}_{hall_id}_{ticket_start_str}"
573
  dedup_key = f"{biz_date}_{hall_id}_{ticket_start_str}_{check_idx}"
574
-
575
  if dedup_key in self.processed_checks: continue
576
  if unique_key in self.cleared_sessions:
577
  self.processed_checks.add(dedup_key)
578
  continue
579
 
580
  hall_name_clean = format_hall_name(hall_name_raw)
581
- status_desc = f"{hall_name_clean} {ticket_start_str} (第{check_idx+1}/3次)"
582
-
583
  if tms_list is not None:
584
  match_found = False
585
  for tms_item in tms_list:
@@ -590,16 +601,16 @@ class PlaybackMonitor:
590
  if abs((t_start_dt - ticket_start_dt).total_seconds()) < 1800:
591
  match_found = True
592
  break
593
-
594
  if match_found:
595
  status_desc += " ⚠️ [空转! TMS未撤]"
596
  self.log(f"🚨 发现空转: {hall_name_clean}《{movie_name}》")
597
-
598
- title = f"💸 发现{hall_name_clean}空场空转!"
599
  msg = (
600
  f"{hall_name_clean} {ticket_start_str}《{movie_name}》\n"
601
  f"无人购票但服务器有排程,未撤场,或许正在播放,请检查。\n"
602
- f"第 {check_idx+1}/3 次检查发现,仅停止播放未撤排程下次检查依然会触发通知。"
603
  )
604
  notifier.send_alert(title, msg, priority=1)
605
  else:
@@ -608,7 +619,7 @@ class PlaybackMonitor:
608
  else:
609
  status_desc += " ❓ [TMS查询失败]"
610
  daily_stats.api_fails += 1
611
-
612
  self.processed_checks.add(dedup_key)
613
  display_results.append(status_desc)
614
 
@@ -620,25 +631,25 @@ class PlaybackMonitor:
620
  def get_monitor():
621
  return PlaybackMonitor()
622
 
 
623
  def main():
624
  monitor = get_monitor()
625
-
626
  if st_autorefresh:
627
  st_autorefresh(interval=30 * 1000, key="pb_refresh")
628
 
629
- st.title("🔋 影厅空场防空转监控 Pro")
630
- st.info("原理:基于票务开场时间,每 5 分钟查询 TMS 服务器排期列表。若 0 票场次仍在 TMS 列表中(未撤场),则视为耗电空转并报警。")
631
 
632
  remaining_zero_count = 0
633
  remaining_total_count = 0
634
  if monitor.daily_schedule_cache:
635
  now = get_beijing_now()
636
  biz_date = monitor.current_business_date or get_business_date()
637
-
638
  for item in monitor.daily_schedule_cache:
639
  start_str = item.get('showStartTime')
640
  if not start_str: continue
641
-
642
  show_dt = parse_show_datetime(biz_date, start_str)
643
  if show_dt and show_dt > now:
644
  remaining_total_count += 1
@@ -656,11 +667,11 @@ def main():
656
  st.divider()
657
 
658
  col_logs, col_mon = st.columns([3, 2])
659
-
660
  with col_logs:
661
  st.subheader("📜 运行日志")
662
  st.text_area("Logs", "\n".join(list(monitor.logs)), height=450, disabled=True)
663
-
664
  with col_mon:
665
  st.subheader("🕵️ 最近一次检查结果")
666
  if monitor.active_monitors:
@@ -674,5 +685,6 @@ def main():
674
  else:
675
  st.caption("等待下一次唤醒...")
676
 
 
677
  if __name__ == "__main__":
678
  main()
 
11
  from dotenv import load_dotenv
12
 
13
  # --- 0. 页面配置 ---
14
+ st.set_page_config(page_title="影城空场防空转监控", page_icon="💸", layout="wide")
15
 
16
  try:
17
  from streamlit_autorefresh import st_autorefresh
 
21
  # --- 1. 配置与常量 ---
22
  load_dotenv()
23
 
24
+ PUSHOVER_TOKEN = os.getenv("PUSHOVER_TOKEN")
25
+ PUSHOVER_USER = os.getenv("PUSHOVER_USER")
26
  # 使用 NTFY 配置
27
+ NTFY_TOPIC = os.getenv("ntfy_code")
28
 
29
+ CINEMA_ID = os.getenv("CINEMA_ID")
30
  TMS_APP_SECRET = os.getenv("TMS_APP_SECRET")
31
  TMS_TICKET = os.getenv("TMS_TICKET")
32
  TMS_X_SESSION_ID = os.getenv("TMS_X_SESSION_ID")
 
46
 
47
  class DailyStats:
48
  """每日运行数据统计"""
49
+
50
  def __init__(self):
51
  self.reset()
52
  self.yesterday_stats = {
 
58
  }
59
 
60
  def reset(self):
61
+ self.zero_sessions = 0 # 0票总场次
62
+ self.triggers = 0 # 触发通知次数
63
+ self.notify_fails = 0 # 发送通知失败次数
64
+ self.api_fails = 0 # API获取失败次数
65
 
66
  def snapshot_as_yesterday(self, date_str):
67
  self.yesterday_stats = {
 
73
  }
74
  self.reset()
75
 
76
+
77
  # 全局统计实例
78
  daily_stats = DailyStats()
79
 
 
82
  utc_now = datetime.now(timezone.utc)
83
  return utc_now.astimezone(timezone(timedelta(hours=8))).replace(tzinfo=None)
84
 
85
+
86
  def get_business_date():
87
  now = get_beijing_now()
88
  if now.time() < dt_time(6, 0):
89
  return (now - timedelta(days=1)).strftime("%Y-%m-%d")
90
  return now.strftime("%Y-%m-%d")
91
 
92
+
93
  def parse_show_datetime(date_str, time_str):
94
  try:
95
  show_t = datetime.strptime(time_str, "%H:%M").time()
 
100
  except:
101
  return None
102
 
103
+
104
  def extract_hall_number_raw(hall_name):
105
  """仅提取数字ID,用于API映射"""
106
  match = re.search(r'(\d+)', str(hall_name))
107
  return match.group(1) if match else str(hall_name)
108
 
109
+
110
  def format_hall_name(hall_name):
111
  """格式化影厅名称:【和成天下1号厅】 -> 1号厅"""
112
  match = re.search(r'(\d+)号', str(hall_name))
 
160
  daily_stats.notify_fails += 1
161
 
162
  self.ntfy_queue.task_done()
163
+
164
  except Exception as e:
165
  print(f"❌ NTFY 线程异常: {e}")
166
  time.sleep(5)
 
174
  def send_daily_report(self, today_date):
175
  """发送每日统计报告"""
176
  y_stats = daily_stats.yesterday_stats
177
+
178
  title = "🟢 影城“空转”检查服务就绪"
179
  msg = (
180
  f"{today_date},今日排片数据已加载,开始智能检查。\n\n"
 
183
  f"触发通知次数:{y_stats['triggers']}\n"
184
  f"发送通知失败次数:{y_stats['notify_fails']}\n"
185
  f"API获取失败次数:{y_stats['api_fails']}\n"
 
186
  )
187
+
188
  self.send_pushover(title, msg, priority=0)
189
  self.ntfy_queue.put((title, msg, 0))
190
 
 
203
  try:
204
  with open(TOKEN_FILE, 'r', encoding='utf-8') as f:
205
  return json.load(f).get('token')
206
+ except:
207
+ pass
208
  return None
209
 
210
  def login(self):
 
222
  try:
223
  session = requests.Session()
224
  login_url = 'https://app.bi.piao51.cn/cinema-app/credential/login.action'
225
+ login_data = {'username': username, 'password': password, 'type': '1', 'resCode': res_code,
226
+ 'deviceid': device_id, 'dtype': 'ios'}
227
  session.post(login_url, data=login_data, timeout=15)
228
  resp = session.get('https://app.bi.piao51.cn/cinema-app/security/logined.action', timeout=10)
229
  info = resp.json()
 
241
  token = self.load_token()
242
  if not token:
243
  token = self.login()
244
+ if not token:
245
  daily_stats.api_fails += 1
246
  return None
247
 
 
275
  def get_token(self):
276
  if self.auth_token and (time.time() - self.last_token_time < 1800):
277
  return self.auth_token
278
+
279
  if not all([TMS_APP_SECRET, TMS_TICKET]): return None
280
 
281
  url = f'http://oa.hengdianfilm.com:7080/cinema-api/admin/generateToken?token=hd&murl=?token=hd&murl=ticket={TMS_TICKET}'
 
289
  self.auth_token = data['param']
290
  self.last_token_time = time.time()
291
  return self.auth_token
292
+ except:
293
+ pass
294
  daily_stats.api_fails += 1
295
  return None
296
 
 
304
  url = 'https://tms.hengdianfilm.com/cinema-api/cinema/schedule/server/list'
305
  session_id = TMS_X_SESSION_ID or ''
306
  cookies = {'JSESSIONID': session_id}
307
+
308
  headers = {
309
  'accept': 'application/json, text/javascript, */*; q=0.01',
310
  'content-type': 'application/json; charset=UTF-8',
 
326
  }
327
 
328
  try:
329
+ res = requests.post(url, params=params, cookies=cookies, headers=headers, json=json_data, timeout=10,
330
+ verify=False)
331
  data = res.json()
332
  if data.get("RSPCD") == "000000":
333
  return data.get("BODY", {}).get("LIST", [])
 
346
  self.status_text = "初始化中..."
347
  self.next_wakeup_str = "--:--:--"
348
  self.active_monitors = []
349
+
350
  self.cleared_sessions = set()
351
  self.processed_checks = set()
352
 
353
  self.ticket_api = TicketAPIManager(self.log)
354
  self.tms_api = TMSAPIManager(self.log)
355
+
356
  self.current_business_date = None
357
  self.daily_schedule_cache = None
358
+
359
  self.thread = threading.Thread(target=self._run_loop, daemon=True)
360
  self.thread.start()
361
 
 
374
  zero_count += 1
375
  start_str = item.get('showStartTime')
376
  if not start_str: continue
377
+
378
  show_dt = parse_show_datetime(business_date, start_str)
379
  if not show_dt: continue
380
+
381
+ for i in range(3):
382
  check_time = show_dt + timedelta(minutes=i * 5)
383
  check_points.append({
384
+ 'time': check_time,
385
+ 'type': 'CHECK',
386
+ 'check_index': i,
387
  'data': item
388
  })
389
+
390
  daily_stats.zero_sessions = zero_count
391
+
392
  check_points.sort(key=lambda x: x['time'])
393
  return check_points
394
 
395
  def _run_loop(self):
396
+ self.log("🚀 智能防空转监控已启动")
397
+
398
  while True:
399
  try:
400
  now = get_beijing_now()
401
  biz_date = get_business_date()
402
+
403
  # --- 初始化与日期变更 ---
404
  need_refresh = False
405
  if self.daily_schedule_cache is None:
 
409
  elif self.current_business_date != biz_date:
410
  if now.time() >= dt_time(9, 0):
411
  need_refresh = True
412
+
413
  daily_stats.snapshot_as_yesterday(self.current_business_date)
414
  self.cleared_sessions.clear()
415
  self.processed_checks.clear()
 
421
  self.daily_schedule_cache = schedule
422
  self.current_business_date = biz_date
423
  self.log(f"✅ 排片已更新,共 {len(schedule)} 场。")
424
+
425
  try:
426
  d_obj = datetime.strptime(biz_date, "%Y-%m-%d")
427
  date_cn = f"{d_obj.year}年{d_obj.month}月{d_obj.day}日"
428
  except:
429
  date_cn = biz_date
430
+
431
  notifier.send_daily_report(date_cn)
432
  else:
433
  self.log("⚠️ 获取排片失败,5分钟后重试")
 
437
 
438
  # --- 任务计算 ---
439
  check_points = self._get_target_check_points(self.daily_schedule_cache, biz_date)
440
+
441
  next_target = None
442
  for cp in check_points:
443
  hall_id = extract_hall_number_raw(cp['data']['hallName'])
444
  start_str = cp['data']['showStartTime']
445
  check_idx = cp['check_index']
446
  dedup_key = f"{biz_date}_{hall_id}_{start_str}_{check_idx}"
447
+
448
  if dedup_key in self.processed_checks:
449
  continue
450
+
451
  if cp['time'] > (now - timedelta(seconds=30)):
452
  next_target = cp
453
  break
454
+
455
  if next_target:
456
  target_time = next_target['time']
457
+
458
  # === 核心修复:09:00 拦截器 ===
459
  # 如果当前时间早于 09:00,且下一场电影晚于 09:00,
460
  # 必须在 09:00 醒来处理新的一天初始化,不能一觉睡过头。
461
  nine_am_today = datetime.combine(now.date(), dt_time(9, 0))
462
+
463
  force_wake_for_daily_reset = False
464
  if now < nine_am_today and target_time > nine_am_today:
465
  target_time = nine_am_today
466
  force_wake_for_daily_reset = True
467
+
468
  sleep_seconds = max(0, (target_time - now).total_seconds())
469
+
470
+ self.status_text = f"💤 休眠中)"
471
  self.next_wakeup_str = target_time.strftime('%H:%M:%S')
472
+
473
  if force_wake_for_daily_reset:
474
  self.active_monitors = ["等待 09:00 日报刷新..."]
475
  else:
476
  raw_hall = next_target['data']['hallName']
477
  clean_hall = format_hall_name(raw_hall)
478
+ self.active_monitors = [
479
+ f"下个任务: {clean_hall} {next_target['data']['showStartTime']} (第{next_target['check_index'] + 1}次检查)"]
480
+
481
  self.log(f"💤 智能休眠 {int(sleep_seconds)}秒,将在 {self.next_wakeup_str} 唤醒...")
482
+
483
  time.sleep(sleep_seconds)
484
+
485
  # 如果是被强制唤醒来做日报刷新的,直接跳过后续的检查逻辑,
486
  # continue 会回到循环头部,触发 if now >= 9:00 的初始化逻辑。
487
  if force_wake_for_daily_reset:
488
  continue
489
+
490
  # --- 正常唤醒检查 ---
491
  self.status_text = "🔥 正在执行检查..."
492
  self.log("⏰ 唤醒!正在同步最新票务数据...")
493
+
494
  latest_schedule = self.ticket_api.fetch_schedule(biz_date)
495
  if latest_schedule:
496
  self.daily_schedule_cache = latest_schedule
 
502
  check_now = get_beijing_now()
503
  current_targets = []
504
  latest_check_points = self._get_target_check_points(latest_schedule, biz_date)
505
+
506
  for cp in latest_check_points:
507
  time_diff = abs((cp['time'] - check_now).total_seconds())
508
+ if time_diff < 120:
509
  current_targets.append(cp)
510
 
511
  if current_targets:
512
  self._process_targets(current_targets, biz_date)
513
+
514
  time.sleep(5)
515
+
516
  else:
517
  self.status_text = "🌙 今日监控结束"
518
  self.active_monitors = []
519
+ tmr_9am = datetime.combine(datetime.strptime(biz_date, "%Y-%m-%d").date() + timedelta(days=1),
520
+ dt_time(9, 0))
521
  seconds_to_tmr = (tmr_9am - now).total_seconds()
522
+
523
  if seconds_to_tmr > 3600:
524
  self.log("暂无目标,休眠 1 小时...")
525
  time.sleep(3600)
 
537
  hall_id = extract_hall_number_raw(t['data']['hallName'])
538
  start_str = t['data']['showStartTime']
539
  check_idx = t['check_index']
540
+
541
  dedup_key = f"{biz_date}_{hall_id}_{start_str}_{check_idx}"
542
  if dedup_key in self.processed_checks:
543
  continue
544
+
545
  if hall_id not in halls_to_check:
546
  halls_to_check[hall_id] = []
547
  halls_to_check[hall_id].append(t)
 
550
 
551
  self.log(f"🔍 核查 {len(halls_to_check)} 个影厅 TMS 状态...")
552
  display_results = []
553
+
554
  for hall_id, target_list in halls_to_check.items():
555
+
556
  all_cleared = True
557
  for target in target_list:
558
  start_str = target['data'].get('showStartTime')
 
560
  if key not in self.cleared_sessions:
561
  all_cleared = False
562
  break
563
+
564
  if all_cleared:
565
  for target in target_list:
566
  dedup_key = f"{biz_date}_{hall_id}_{target['data']['showStartTime']}_{target['check_index']}"
567
  self.processed_checks.add(dedup_key)
568
+
569
  clean_name = format_hall_name(target_list[0]['data']['hallName'])
570
  display_results.append(f"{clean_name} ✅ [已跳过 (已确认撤场)]")
571
  continue
572
 
573
  tms_list = self.tms_api.fetch_hall_schedule_list(hall_id)
574
+
575
  for target in target_list:
576
  session = target['data']
577
  check_idx = target['check_index']
578
  hall_name_raw = session.get('hallName')
579
  movie_name = session.get('movieName')
580
+ ticket_start_str = session.get('showStartTime')
581
  ticket_start_dt = parse_show_datetime(biz_date, ticket_start_str)
582
+
583
  unique_key = f"{biz_date}_{hall_id}_{ticket_start_str}"
584
  dedup_key = f"{biz_date}_{hall_id}_{ticket_start_str}_{check_idx}"
585
+
586
  if dedup_key in self.processed_checks: continue
587
  if unique_key in self.cleared_sessions:
588
  self.processed_checks.add(dedup_key)
589
  continue
590
 
591
  hall_name_clean = format_hall_name(hall_name_raw)
592
+ status_desc = f"{hall_name_clean} {ticket_start_str} (第{check_idx + 1}/3次)"
593
+
594
  if tms_list is not None:
595
  match_found = False
596
  for tms_item in tms_list:
 
601
  if abs((t_start_dt - ticket_start_dt).total_seconds()) < 1800:
602
  match_found = True
603
  break
604
+
605
  if match_found:
606
  status_desc += " ⚠️ [空转! TMS未撤]"
607
  self.log(f"🚨 发现空转: {hall_name_clean}《{movie_name}》")
608
+
609
+ title = f"💸 发现 {hall_name_clean} 空场“空转”!"
610
  msg = (
611
  f"{hall_name_clean} {ticket_start_str}《{movie_name}》\n"
612
  f"无人购票但服务器有排程,未撤场,或许正在播放,请检查。\n"
613
+ f"第 {check_idx + 1}/3 次检查发现,仅停止播放未撤排程下次检查依然会触发通知。"
614
  )
615
  notifier.send_alert(title, msg, priority=1)
616
  else:
 
619
  else:
620
  status_desc += " ❓ [TMS查询失败]"
621
  daily_stats.api_fails += 1
622
+
623
  self.processed_checks.add(dedup_key)
624
  display_results.append(status_desc)
625
 
 
631
  def get_monitor():
632
  return PlaybackMonitor()
633
 
634
+
635
  def main():
636
  monitor = get_monitor()
637
+
638
  if st_autorefresh:
639
  st_autorefresh(interval=30 * 1000, key="pb_refresh")
640
 
641
+ st.title("💸 影厅空场防空转监控")
 
642
 
643
  remaining_zero_count = 0
644
  remaining_total_count = 0
645
  if monitor.daily_schedule_cache:
646
  now = get_beijing_now()
647
  biz_date = monitor.current_business_date or get_business_date()
648
+
649
  for item in monitor.daily_schedule_cache:
650
  start_str = item.get('showStartTime')
651
  if not start_str: continue
652
+
653
  show_dt = parse_show_datetime(biz_date, start_str)
654
  if show_dt and show_dt > now:
655
  remaining_total_count += 1
 
667
  st.divider()
668
 
669
  col_logs, col_mon = st.columns([3, 2])
670
+
671
  with col_logs:
672
  st.subheader("📜 运行日志")
673
  st.text_area("Logs", "\n".join(list(monitor.logs)), height=450, disabled=True)
674
+
675
  with col_mon:
676
  st.subheader("🕵️ 最近一次检查结果")
677
  if monitor.active_monitors:
 
685
  else:
686
  st.caption("等待下一次唤醒...")
687
 
688
+
689
  if __name__ == "__main__":
690
  main()