抖音直播无人值守全天候轮询录制工具2.0

151次阅读
没有评论

共计 97699 个字符,预计需要花费 245 分钟才能阅读完成。

前端代码仅展示部分。请在下方下载完整源码。
先看效果图:MoonTV 抖音直播监控系统项目总结 项目概述

本项目是一个抖音直播监控和录制系统,具有多直播间管理、自动轮询检查、直播录制等功能。前端使用 Vue.js 构建,后端使用 Python Flask 框架实现。

核心功能1. 多直播间管理

  • 支持同时监控多个直播间的在线状态
  • 自动轮询检查直播间状态(默认 60 秒间隔,可自定义)
  • 显示直播间详细信息(房间 ID、主播名、在线人数等)

2. 直播录制功能

  • 支持手动开始 / 停止录制
  • 支持开播时自动录制(可选)
  • 录制文件保存在本地

3. 播放器功能

  • 支持 FLV 直播流播放
  • 页面内嵌式播放器(非弹窗)
  • 支持多个播放器同时播放
  • 播放器默认静音,点击播放后取消静音
  • 播放器标题显示为主播名或房间 ID

4. 批量操作

  • 支持多选直播间
  • 批量开始 / 停止录制
  • 批量暂停 / 恢复轮询
  • 批量移除直播间

5. 历史记录

  • 记录直播间轮询历史
  • 显示主播名、直播间地址和时间信息

技术架构 前端 (douyin-frontend)

  • 框架:Vue.js 3
  • 样式:Tailwind CSS
  • 播放器:flv.js
  • 构建工具:Vue CLI

后端 (douyin-backend)

  • 框架:Python Flask
  • 多线程:threading 模块
  • HTTP 请求:requests 库
  • 数据存储:JSON 文件(saved_rooms.json, rooms_history.json)

主要文件结构

  1. MoonTV-main/
  2. ├── douyin-frontend/
  3. │   ├── src/
  4. │   │   ├── App.vue (主应用组件)
  5. │   │   ├── MultiRoomManager.vue (多直播间管理器)
  6. │   │   └── assets/ (静态资源)
  7. │   ├── public/
  8. │   └── package.json
  9. ├── douyin-backend/
  10. │   ├── app.py (主应用文件)
  11. │   ├── saved_rooms.json (保存的直播间配置)
  12. │   ├── rooms_history.json (轮询历史记录)
  13. │   └── recordings/ (录制文件目录)
  14. └── docs/
  15.     └── PROJECT_SUMMARY.md (项目说明文档)

API 接口 多直播间管理接口

  • GET /api/multi-poll/status – 获取所有直播间状态
  • POST /api/multi-poll/add – 添加直播间
  • POST /api/multi-poll/remove – 移除直播间
  • POST /api/multi-poll/start-record – 开始录制
  • POST /api/multi-poll/stop-record – 停止录制
  • POST /api/multi-poll/pause – 暂停轮询
  • POST /api/multi-poll/resume – 恢复轮询
  • GET /api/multi-poll/history – 获取历史记录

重要功能实现细节1. 暂停功能

暂停不仅停止录制,还会停止轮询检查,确保完全暂停直播间监控。

2. 播放器实现

  • 使用 flv.js 库支持 FLV 直播流播放
  • 页面内嵌式播放器,支持多个播放器同时播放
  • 默认静音状态,点击播放后取消静音
  • 播放器标题显示为主播名或房间 ID

3. 数据持久化

  • 直播间配置保存在 saved_rooms.json
  • 轮询历史记录保存在 rooms_history.json
  • 录制文件保存在 recordings 目录下

启动方式

打开 CMD
CD 到项目目录下

后端服务

  1. python app.py

前端服务

  1. cd douyin-frontend
  2. npm install  # 首次运行需要安装依赖
  3. npm run serve

项目特点

  • 开箱即用,无需复杂配置
  • 支持多直播间同时监控
  • 自动录制功能
  • 数据本地持久化存储
  • 历史记录去重功能
  • 支持手机端短链接解析
  • 可获取直播间实时数据(如在线人数等)

使用场景

  • 直播平台观众数据监控
  • 网红经济数据分析系统
  • 直播带货效果评估工具
  • 多平台直播状态监控中心

后端:

  1. from flask import Flask, request, jsonify
  2. from flask_cors import CORS
  3. import requests
  4. import re
  5. import time
  6. import os
  7. import subprocess
  8. import threading
  9. import json
  10. import logging
  11. from datetime import datetime
  12. from functools import wraps
  13. app = Flask(__name__)
  14. CORS(app, resources={r”/*”: {“origins”: [“http://127.0.0.1:8080”, “http://localhost:8080”]}}, supports_credentials=True)
  15. # 配置日志
  16. logging.basicConfig(level=logging.INFO, format=’%(asctime)s – %(levelname)s – %(message)s’)
  17. logger = logging.getLogger(__name__)
  18. # 全局变量
  19. recording_sessions = {}
  20. recording_lock = threading.Lock()
  21. # 新增:多直播间轮询管理
  22. polling_sessions = {}
  23. polling_lock = threading.Lock()
  24. # 异常处理装饰器
  25. def handle_exceptions(func):
  26.     @wraps(func)
  27.     def wrapper(*args, **kwargs):
  28.         try:
  29.             return func(*args, **kwargs)
  30.         except Exception as e:
  31.             logger.error(f” 函数 {func.__name__} 执行失败: {str(e)}”, exc_info=True)
  32.             return jsonify({
  33.                 ‘success’: False,
  34.                 ‘message’: f’ 服务器内部错误: {str(e)}’
  35.             }), 500
  36.     return wrapper
  37. def get_real_stream_url(url, max_retries=3):
  38.     “””
  39.     解析抖音直播链接,获取真实的直播流地址
  40.     :param url: 抖音直播链接
  41.     :param max_retries: 最大重试次数
  42.     :return: 直播流地址或 None
  43.     “””
  44.     # 存储捕获到的直播流地址的变量,放在循环外部以便在所有尝试结束后仍能访问
  45.     captured_stream_urls = []
  46.     for attempt in range(max_retries):
  47.         try:
  48.             from playwright.sync_api import sync_playwright
  49.             with sync_playwright() as p:
  50.                 # 启动浏览器(无头模式)
  51.                 browser = p.chromium.launch(headless=True)
  52.                 context = browser.new_context(
  53.                     user_agent=”Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36″,
  54.                     viewport={“width”: 1920, “height”: 1080}
  55.                 )
  56.                 page = context.new_page()
  57.                 # 创建一个事件,用于在捕获到直播流地址时通知主线程
  58.                 stream_captured_event = threading.Event()
  59.                 # 处理 URL 格式
  60.                 if not url.startswith(“http”):
  61.                     url = f”https://live.douyin.com/{url}”
  62.                     logger.info(f” 转换为完整 URL: {url}”)
  63.                 # 访问直播页面
  64.                 logger.info(f”[尝试{attempt + 1}] 开始访问页面: {url}”)
  65.                 page.goto(url, timeout=30000, wait_until=”domcontentloaded”)
  66.                 # 定义在捕获到直播流地址时的处理函数
  67.                 def on_stream_captured(url):
  68.                     logger.info(f”[尝试{attempt + 1}] 成功捕获到直播流地址: {url}”)
  69.                     if url not in captured_stream_urls:
  70.                         captured_stream_urls.append(url)
  71.                         logger.info(f”[尝试{attempt + 1}] 已保存直播流地址,当前共 {len(captured_stream_urls)} 个 ”)
  72.                         # 立即设置事件,通知主线程已捕获到直播流地址
  73.                         stream_captured_event.set()
  74.                         logger.info(f”[尝试{attempt + 1}] 已通知主线程捕获到直播流地址 ”)
  75.                 # 添加网络请求监听函数
  76.                 def handle_response(response):
  77.                     try:
  78.                         response_url = response.url
  79.                         if (response_url.endswith(‘.m3u8’) or
  80.                             response_url.endswith(‘.flv’) or
  81.                             (‘.flv?’ in response_url) or
  82.                             (‘.m3u8?’ in response_url) or
  83.                             (‘douyincdn.com’ in response_url and (‘stream’ in response_url or ‘pull’ in response_url)) or
  84.                             (‘video’ in response.headers.get(‘content-type’, ”) and not response_url.endswith(‘.mp4’))):
  85.                             on_stream_captured(response_url)
  86.                     except Exception as e:
  87.                         logger.warning(f” 处理响应失败: {e}”)
  88.                 page.on(“response”, handle_response)
  89.                 # 直接等待网络请求,最多等待 10 秒
  90.                 max_wait_time = 10
  91.                 logger.info(f”[尝试{attempt + 1}] 开始等待直播流地址捕获 …”)
  92.                 # 等待事件或超时
  93.                 for elapsed_time in range(1, max_wait_time + 1):
  94.                     # 先检查是否已经捕获到直播流地址
  95.                     if captured_stream_urls:
  96.                         logger.info(f”[尝试{attempt + 1}] 检测到已捕获 {len(captured_stream_urls)} 个直播流地址 ”)
  97.                         context.close()
  98.                         return captured_stream_urls[0]  # 返回第一个捕获到的地址
  99.                     # 等待事件通知
  100.                     if stream_captured_event.wait(1):  # 等待 1 秒
  101.                         logger.info(f”[尝试{attempt + 1}] 在 {elapsed_time} 秒后收到直播流地址捕获通知 ”)
  102.                         context.close()
  103.                         return captured_stream_urls[0]  # 返回第一个捕获到的地址
  104.                     # 每 2 秒输出一次等待日志
  105.                     if elapsed_time % 2 == 0:
  106.                         logger.info(f”[尝试 {attempt + 1}] 等待网络请求中 … ({elapsed_time}/{max_wait_time} 秒)”)
  107.                 # 等待结束后最后检查一次变量
  108.                 if captured_stream_urls:  # 变量不为空
  109.                     logger.info(f”[尝试{attempt + 1}] 等待结束后发现 {len(captured_stream_urls)} 个直播流地址 ”)
  110.                     context.close()
  111.                     return captured_stream_urls[0]
  112.                 else:
  113.                     logger.warning(f”[尝试{attempt + 1}] 等待结束后仍未捕获到直播流地址 ”)
  114.                 # 保存页面内容用于调试
  115.                 try:
  116.                     with open(‘debug_page_content.html’, ‘w’, encoding=’utf-8′) as f:
  117.                         f.write(page.content())
  118.                 except Exception as e:
  119.                     logger.warning(f” 保存调试文件失败: {e}”)
  120.                 # 最后一次检查是否捕获到直播流地址
  121.                 if captured_stream_urls:
  122.                     logger.info(f”[尝试{attempt + 1}] 关闭浏览器前发现已捕获到直播流地址 ”)
  123.                     context.close()
  124.                     return captured_stream_urls[0]
  125.                 context.close()
  126.                 if attempt < max_retries – 1:
  127.                     logger.info(f” 第 {attempt + 1} 次尝试失败,准备第 {attempt + 2} 次尝试 …”)
  128.                     time.sleep(2)  # 重试前等待
  129.         except Exception as e:
  130.             logger.error(f” 解析直播流地址失败 (尝试 {attempt + 1}): {str(e)}”)
  131.             # 即使发生异常,也检查是否已经捕获到直播流地址
  132.             if captured_stream_urls:
  133.                 logger.info(f”[尝试{attempt + 1}] 尽管发生异常,但已捕获到直播流地址 ”)
  134.                 return captured_stream_urls[0]
  135.             if attempt < max_retries – 1:
  136.                 time.sleep(2)
  137.             continue
  138.     # 最后一次检查是否有捕获到的直播流地址
  139.     if captured_stream_urls:
  140.         logger.info(f” 虽然所有 {max_retries} 次尝试报告失败,但已捕获到 {len(captured_stream_urls)} 个直播流地址 ”)
  141.         return captured_stream_urls[0]
  142.     logger.error(f” 所有 {max_retries} 次尝试均失败,未能捕获到直播流地址 ”)
  143.     return None
  144. def parse_viewer_count(text):
  145.     “””
  146.     解析观看人数文本为数字
  147.     例: “32 人在线 ” -> 32, “1.2 万人在看 ” -> 12000, “5000 人在看 ” -> 5000
  148.     “””
  149.     try:
  150.         # 移除常见的文字,保留数字和单位
  151.         clean_text = re.sub(r'[人在看观气线众]’, ”, text)
  152.         # 查找数字和单位
  153.         match = re.search(r'(\d+(?:\.\d+)?)\s*([万 w])?’, clean_text, re.IGNORECASE)
  154.         if match:
  155.             number = float(match.group(1))
  156.             unit = match.group(2)
  157.             # 如果有 ” 万 ” 或 ”w” 单位,乘以 10000
  158.             if unit and unit.lower() in [‘ 万 ’, ‘w’]:
  159.                 number *= 10000
  160.             return int(number)
  161.     except Exception as e:
  162.         logger.debug(f” 解析观看人数失败: {e}”)
  163.     return 0
  164. def get_live_room_info(url, max_retries=3):
  165.     “””
  166.     获取直播间详细信息,包括在线人数
  167.     :param url: 抖音直播链接
  168.     :param max_retries: 最大重试次数
  169.     :return: 包含在线人数等信息的字典
  170.     “””
  171.     room_info = {
  172.         ‘online_count’: 0,
  173.         ‘is_live’: False,
  174.         ‘stream_url’: None,
  175.         ‘room_title’: ”,
  176.         ‘anchor_name’: ”,
  177.         ‘room_id’: ”,
  178.         ‘viewer_count_text’: ”  # 显示的观看人数文本(如 ”1.2 万人在看 ”)
  179.     }
  180.     for attempt in range(max_retries):
  181.         try:
  182.             from playwright.sync_api import sync_playwright
  183.             with sync_playwright() as p:
  184.                 browser = p.chromium.launch(headless=True)
  185.                 context = browser.new_context(
  186.                     user_agent=”Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36″,
  187.                     viewport={“width”: 1920, “height”: 1080}
  188.                 )
  189.                 page = context.new_page()
  190.                 # 存储捕获的数据
  191.                 captured_data = {
  192.                     ‘stream_urls’: [],
  193.                     ‘api_responses’: []
  194.                 }
  195.                 # 处理 URL 格式
  196.                 if not url.startswith(“http”):
  197.                     url = f”https://live.douyin.com/{url}”
  198.                 logger.info(f”[尝试{attempt + 1}] 开始获取直播间信息: {url}”)
  199.                 # 监听网络请求,捕获 API 响应
  200.                 def handle_response(response):
  201.                     try:
  202.                         response_url = response.url
  203.                         # 捕获直播流地址
  204.                         if (response_url.endswith(‘.m3u8’) or
  205.                             response_url.endswith(‘.flv’) or
  206.                             (‘.flv?’ in response_url) or
  207.                             (‘.m3u8?’ in response_url) or
  208.                             (‘douyincdn.com’ in response_url and (‘stream’ in response_url or ‘pull’ in response_url))):
  209.                             captured_data[‘stream_urls’].append(response_url)
  210.                             logger.info(f” 捕获到直播流: {response_url}”)
  211.                         # 捕获包含直播间信息的 API 响应
  212.                         if (‘webcast/room/’ in response_url or
  213.                             ‘webcast/web/’ in response_url or
  214.                             ‘/api/live_data/’ in response_url or
  215.                             ‘room_id’ in response_url):
  216.                             try:
  217.                                 if response.status == 200:
  218.                                     response_json = response.json()
  219.                                     captured_data[‘api_responses’].append({
  220.                                         ‘url’: response_url,
  221.                                         ‘data’: response_json
  222.                                     })
  223.                                     logger.info(f” 捕获到 API 响应: {response_url}”)
  224.                             except Exception as json_error:
  225.                                 logger.debug(f”API 响应解析失败: {json_error}”)
  226.                     except Exception as e:
  227.                         logger.debug(f” 处理响应失败: {e}”)
  228.                 page.on(“response”, handle_response)
  229.                 # 访问直播页面
  230.                 page.goto(url, timeout=30000, wait_until=”domcontentloaded”)
  231.                 # 等待页面加载并捕获网络请求
  232.                 time.sleep(5)
  233.                 # 尝试从页面元素获取信息
  234.                 try:
  235.                     # 方法 1: 通过页面元素获取在线人数 – 更精确的选择器
  236.                     online_selectors = [
  237.                         ‘[data-e2e=”living-avatar-name”]’,
  238.                         ‘[class*=”viewer”][class*=”count”]’,
  239.                         ‘[class*=”online”][class*=”count”]’,
  240.                         ‘[class*=”watching”][class*=”count”]’,
  241.                         ‘span:has-text(“ 在线观众 ”)’,
  242.                         ‘span:has-text(“ 观众 ”)’,
  243.                         ‘div:has-text(“ 在线观众 ”)’,
  244.                         ‘.webcast-chatroom___content span’
  245.                     ]
  246.                     viewer_text = “”
  247.                     # 首先尝试找到 ” 在线观众 ” 相关的元素
  248.                     for selector in online_selectors:
  249.                         try:
  250.                             elements = page.query_selector_all(selector)
  251.                             for element in elements:
  252.                                 text = element.inner_text().strip()
  253.                                 # 更严格的匹配条件,只要包含 ” 在线观众 ” 或纯数字的
  254.                                 if (‘ 在线观众 ’ in text or ‘ 观众 ’ in text) and any(c.isdigit() for c in text):
  255.                                     # 提取 ” 在线观众 · 32″ 这样的格式
  256.                                     import re
  257.                                     match = re.search(r’ 在线观众[\s·]*([\d,]+)’, text)
  258.                                     if match:
  259.                                         viewer_text = f”{match.group(1)}人在线 ”
  260.                                         logger.info(f” 找到在线观众数: {viewer_text}”)
  261.                                         break
  262.                                     # 或者提取 ” 观众 32″ 这样的格式
  263.                                     match = re.search(r’ 观众[\s·]*([\d,]+)’, text)
  264.                                     if match:
  265.                                         viewer_text = f”{match.group(1)}人在线 ”
  266.                                         logger.info(f” 找到观众数: {viewer_text}”)
  267.                                         break
  268.                             if viewer_text:
  269.                                 break
  270.                         except Exception as e:
  271.                             logger.debug(f” 选择器 {selector} 解析失败: {e}”)
  272.                     # 如果没找到,尝试从页面内容中提取 ” 在线观众 ” 信息
  273.                     if not viewer_text:
  274.                         page_content = page.content()
  275.                         # 使用正则表达式精确匹配 ” 在线观众 · 数字 ” 格式
  276.                         patterns = [
  277.                             r’ 在线观众[\s·]*([\d,]+)’,
  278.                             r’ 观众[\s·]*([\d,]+)’,
  279.                             r'(\d+)\s* 人在线 ’,
  280.                             r'(\d+)\s* 观看 ’
  281.                         ]
  282.                         for pattern in patterns:
  283.                             matches = re.findall(pattern, page_content)
  284.                             if matches:
  285.                                 # 取第一个匹配的数字
  286.                                 count_str = matches[0].replace(‘,’, ”)  # 移除千分位逗号
  287.                                 try:
  288.                                     count = int(count_str)
  289.                                     viewer_text = f”{count}人在线 ”
  290.                                     logger.info(f” 通过正则表达式获取到观众数: {viewer_text}”)
  291.                                     break
  292.                                 except ValueError:
  293.                                     continue
  294.                     # 解析人数文本为数字
  295.                     if viewer_text:
  296.                         room_info[‘viewer_count_text’] = viewer_text
  297.                         online_count = parse_viewer_count(viewer_text)
  298.                         room_info[‘online_count’] = online_count
  299.                 except Exception as e:
  300.                     logger.warning(f” 从页面元素获取在线人数失败: {e}”)
  301.                 # 方法 2: 从 API 响应中提取信息
  302.                 for api_resp in captured_data[‘api_responses’]:
  303.                     try:
  304.                         data = api_resp[‘data’]
  305.                         # 抖音 API 响应结构可能包含以下字段
  306.                         if ‘data’ in data:
  307.                             room_data = data[‘data’]
  308.                             # 在线人数
  309.                             if ‘user_count’ in room_data:
  310.                                 room_info[‘online_count’] = max(room_info[‘online_count’], room_data[‘user_count’])
  311.                             elif ‘stats’ in room_data and ‘user_count’ in room_data[‘stats’]:
  312.                                 room_info[‘online_count’] = max(room_info[‘online_count’], room_data[‘stats’][‘user_count’])
  313.                             elif ‘room_view_stats’ in room_data:
  314.                                 room_info[‘online_count’] = max(room_info[‘online_count’], room_data[‘room_view_stats’].get(‘display_long’, 0))
  315.                             # 直播状态
  316.                             if ‘status’ in room_data:
  317.                                 room_info[‘is_live’] = room_data[‘status’] == 2  # 2 通常表示正在直播
  318.                             # 房间标题
  319.                             if ‘title’ in room_data:
  320.                                 room_info[‘room_title’] = room_data[‘title’]
  321.                             # 主播名称
  322.                             if ‘owner’ in room_data and ‘nickname’ in room_data[‘owner’]:
  323.                                 room_info[‘anchor_name’] = room_data[‘owner’][‘nickname’]
  324.                             # 房间 ID
  325.                             if ‘id_str’ in room_data:
  326.                                 room_info[‘room_id’] = room_data[‘id_str’]
  327.                     except Exception as e:
  328.                         logger.debug(f” 解析 API 响应失败: {e}”)
  329.                 # 设置直播流地址
  330.                 if captured_data[‘stream_urls’]:
  331.                     room_info[‘stream_url’] = captured_data[‘stream_urls’][0]
  332.                     room_info[‘is_live’] = True
  333.                 # 如果没有从 API 获取到在线人数,尝试页面内容检测
  334.                 if room_info[‘online_count’] == 0 and not room_info[‘viewer_count_text’]:
  335.                     try:
  336.                         page_content = page.content()
  337.                         # 使用更精确的正则表达式从页面内容中提取人数
  338.                         patterns = [
  339.                             r’ 在线观众[\s·]*([\d,]+)’,  # “ 在线观众 · 32”
  340.                             r’ 观众[\s·]*([\d,]+)’,      # “ 观众 32”
  341.                             r'”user_count[“\s]*:\s*(\d+)’,
  342.                             r'”viewer_count[“\s]*:\s*(\d+)’,
  343.                         ]
  344.                         for pattern in patterns:
  345.                             matches = re.findall(pattern, page_content, re.IGNORECASE)
  346.                             if matches:
  347.                                 try:
  348.                                     count_str = matches[0].replace(‘,’, ”)  # 移除千分位逗号
  349.                                     count = int(count_str)
  350.                                     room_info[‘online_count’] = count
  351.                                     room_info[‘viewer_count_text’] = f”{count}人在线 ”
  352.                                     logger.info(f” 通过正则表达式获取到人数: {room_info[‘online_count’]}”)
  353.                                     break
  354.                                 except ValueError:
  355.                                     continue
  356.                     except Exception as e:
  357.                         logger.warning(f” 页面内容解析失败: {e}”)
  358.                 context.close()
  359.                 # 如果获取到了有效信息就返回
  360.                 if room_info[‘online_count’] > 0 or room_info[‘stream_url’] or room_info[‘is_live’]:
  361.                     logger.info(f” 成功获取直播间信息: 在线人数 ={room_info[‘online_count’]}, 直播状态 ={room_info[‘is_live’]}”)
  362.                     return room_info
  363.         except Exception as e:
  364.             logger.error(f” 获取直播间信息失败 (尝试 {attempt + 1}): {str(e)}”)
  365.             if attempt < max_retries – 1:
  366.                 time.sleep(2)
  367.             continue
  368.     logger.error(f” 所有 {max_retries} 次尝试均失败,无法获取直播间信息 ”)
  369.     return room_info
  370. @app.route(‘/’)
  371. @handle_exceptions
  372. def home():
  373.     return jsonify({
  374.         ‘message’: ‘ 抖音直播解析后端服务已启动 ’,
  375.         ‘api’: [‘/api/parse’, ‘/api/room-info’, ‘/api/monitor’, ‘/api/record/start’, ‘/api/record/stop’, ‘/api/record/status’]
  376.     })
  377. @app.route(‘/api/parse’, methods=[‘POST’])
  378. @handle_exceptions
  379. def parse_live_stream():
  380.     data = request.get_json()
  381.     url = data.get(‘url’)
  382.     if not url:
  383.         return jsonify({
  384.             ‘success’: False,
  385.             ‘message’: ‘ 无效的直播链接或主播 ID’
  386.         })
  387.     # 处理不同格式的输入
  388.     processed_url = url.strip()
  389.     logger.info(f” 收到解析请求,原始输入: {processed_url}”)
  390.     # 1. 检查是否是纯数字(主播 ID)
  391.     if re.match(r’^\d+$’, processed_url):
  392.         logger.info(f” 检测到主播 ID 格式: {processed_url}”)
  393.         room_id = processed_url
  394.         full_url = f”https://live.douyin.com/{room_id}”
  395.     # 2. 检查是否是完整的抖音直播 URL
  396.     elif “douyin.com” in processed_url:
  397.         logger.info(f” 检测到抖音 URL 格式: {processed_url}”)
  398.         # 提取房间号
  399.         if “/user/” in processed_url:
  400.             # 用户主页 URL
  401.             logger.info(“ 检测到用户主页 URL,尝试提取用户 ID”)
  402.             user_id_match = re.search(r’/user/([^/?]+)’, processed_url)
  403.             if user_id_match:
  404.                 room_id = user_id_match.group(1)
  405.                 full_url = f”https://live.douyin.com/{room_id}”
  406.             else:
  407.                 return jsonify({
  408.                     ‘success’: False,
  409.                     ‘message’: ‘ 无法从用户主页 URL 提取用户 ID’
  410.                 })
  411.         else:
  412.             # 直播间 URL
  413.             room_id_match = re.search(r’live\.douyin\.com/([^/?]+)’, processed_url)
  414.             if room_id_match:
  415.                 room_id = room_id_match.group(1)
  416.                 full_url = f”https://live.douyin.com/{room_id}”
  417.             else:
  418.                 # 尝试直接使用
  419.                 room_id = processed_url
  420.                 full_url = processed_url
  421.     # 3. 其他格式(可能是短链接或其他标识符)
  422.     else:
  423.         logger.info(f” 未识别的 URL 格式,尝试直接使用: {processed_url}”)
  424.         room_id = processed_url
  425.         full_url = processed_url
  426.     logger.info(f” 处理后的房间 ID: {room_id}, 完整 URL: {full_url}”)
  427.     # 调用解析函数获取直播流地址
  428.     real_stream_url = get_real_stream_url(full_url)
  429.     if real_stream_url:
  430.         logger.info(f” 成功解析直播流地址: {real_stream_url}”)
  431.         return jsonify({
  432.             ‘success’: True,
  433.             ‘streamUrl’: real_stream_url,
  434.             ‘roomId’: room_id,
  435.             ‘fullUrl’: full_url
  436.         })
  437.     else:
  438.         logger.warning(f” 无法解析直播流地址,输入: {processed_url}”)
  439.         return jsonify({
  440.             ‘success’: False,
  441.             ‘message’: ‘ 无法解析直播链接,请确认主播是否开播 ’
  442.         })
  443. # 新增:获取直播间详细信息的 API 接口
  444. @app.route(‘/api/room-info’, methods=[‘POST’])
  445. @handle_exceptions
  446. def get_room_info():
  447.     “”” 获取直播间详细信息,包括在线人数 ”””
  448.     data = request.get_json()
  449.     url = data.get(‘url’)
  450.     if not url:
  451.         return jsonify({
  452.             ‘success’: False,
  453.             ‘message’: ‘ 无效的直播链接或主播 ID’
  454.         })
  455.     # 处理 URL 格式
  456.     processed_url = url.strip()
  457.     logger.info(f” 收到直播间信息请求: {processed_url}”)
  458.     # URL 格式处理逻辑(与 parse_live_stream 相同)
  459.     if re.match(r’^\d+$’, processed_url):
  460.         full_url = f”https://live.douyin.com/{processed_url}”
  461.     elif “douyin.com” in processed_url:
  462.         full_url = processed_url
  463.     else:
  464.         full_url = processed_url
  465.     # 获取直播间信息
  466.     room_info = get_live_room_info(full_url)
  467.     if room_info[‘is_live’] or room_info[‘online_count’] > 0:
  468.         return jsonify({
  469.             ‘success’: True,
  470.             ‘data’: {
  471.                 ‘online_count’: room_info[‘online_count’],
  472.                 ‘viewer_count_text’: room_info[‘viewer_count_text’],
  473.                 ‘is_live’: room_info[‘is_live’],
  474.                 ‘stream_url’: room_info[‘stream_url’],
  475.                 ‘room_title’: room_info[‘room_title’],
  476.                 ‘anchor_name’: room_info[‘anchor_name’],
  477.                 ‘room_id’: room_info[‘room_id’]
  478.             }
  479.         })
  480.     else:
  481.         return jsonify({
  482.             ‘success’: False,
  483.             ‘message’: ‘ 直播间未开播或无法获取信息 ’,
  484.             ‘data’: room_info
  485.         })
  486. def get_anchor_info(anchor_id, max_retries=2):
  487.     “””
  488.     获取主播信息(名字、直播状态等)
  489.     :param anchor_id: 主播 ID
  490.     :param max_retries: 最大重试次数
  491.     :return: dict 包含 {“is_live”: bool, “name”: str, “title”: str}
  492.     “””
  493.     for attempt in range(max_retries):
  494.         try:
  495.             from playwright.sync_api import sync_playwright
  496.             import random
  497.             with sync_playwright() as p:
  498.                 # 启动浏览器(无头模式)
  499.                 browser = p.chromium.launch(headless=True)
  500.                 context = browser.new_context(
  501.                     user_agent=”Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36″,
  502.                     extra_http_headers={
  503.                         “Referer”: “https://www.douyin.com/”,
  504.                         “Accept-Language”: “zh-CN,zh;q=0.9”
  505.                     },
  506.                     viewport={“width”: 1920, “height”: 1080},
  507.                     java_script_enabled=True
  508.                 )
  509.                 page = context.new_page()
  510.                 # 随机延迟(1- 3 秒),模拟人类操作
  511.                 time.sleep(random.uniform(1, 3))
  512.                 # 访问直播间页面
  513.                 try:
  514.                     # 处理 URL 格式,确保不重复添加域名
  515.                     if anchor_id.startswith(“https://live.douyin.com/”):
  516.                         url = anchor_id
  517.                         room_id = anchor_id.split(“/”)[-1]
  518.                     else:
  519.                         url = f”https://live.douyin.com/{anchor_id}”
  520.                         room_id = anchor_id
  521.                     logger.info(f”[尝试{attempt + 1}] 开始访问直播间页面: {url}”)
  522.                     page.goto(url, timeout=30000, wait_until=”domcontentloaded”)
  523.                     logger.info(f”[尝试{attempt + 1}] 成功访问直播间页面 ”)
  524.                 except Exception as e:
  525.                     if “Timeout” in str(e):
  526.                         logger.warning(f”[尝试{attempt + 1}] 页面加载超时,继续处理 ”)
  527.                     else:
  528.                         logger.error(f”[尝试{attempt + 1}] 访问直播间页面失败: {e}”)
  529.                         context.close()
  530.                         continue
  531.                 # 等待页面加载
  532.                 try:
  533.                     logger.info(f”[尝试{attempt + 1}] 等待页面关键元素加载 …”)
  534.                     page.wait_for_selector(“body”, timeout=10000)
  535.                     # 额外等待,确保页面完全加载
  536.                     time.sleep(3)
  537.                 except Exception as wait_e:
  538.                     logger.warning(f”[尝试{attempt + 1}] 等待元素失败: {wait_e},继续处理 ”)
  539.                 # 获取页面内容
  540.                 content = page.content()
  541.                 logger.info(f”[尝试{attempt + 1}] 页面内容长度: {len(content)} 字符 ”)
  542.                 # 提取主播信息
  543.                 anchor_info = {
  544.                     “is_live”: False,
  545.                     “name”: f”anchor_{room_id}”,  # 默认名字
  546.                     “title”: “”
  547.                 }
  548.                 # 尝试获取主播名字
  549.                 logger.info(f”[尝试{attempt + 1}] 开始尝试获取主播名字 …”)
  550.                 # 策略 1: 尝试从页面标题获取(优先策略)
  551.                 try:
  552.                     title = page.title()
  553.                     logger.info(f”[尝试{attempt + 1}] 页面标题: {title}”)
  554.                     # 抖音直播间标题格式分析
  555.                     if title and title != “ 抖音直播 ”:
  556.                         # 格式 1: “ 主播名字的直播间 ”
  557.                         if “ 的直播间 ” in title:
  558.                             name_from_title = title.split(“ 的直播间 ”)[0].strip()
  559.                             if name_from_title and len(name_from_title) < 50 and name_from_title != room_id:
  560.                                 anchor_info[“name”] = name_from_title
  561.                                 logger.info(f”[尝试{attempt + 1}] 从页面标题获取到主播名字: {name_from_title}”)
  562.                         # 格式 2: “ 主播名字 – 抖音直播 ”
  563.                         elif ” – 抖音 ” in title or ” – 直播 ” in title:
  564.                             parts = title.split(” – “)
  565.                             if len(parts) > 0:
  566.                                 potential_name = parts[0].strip()
  567.                                 if potential_name and len(potential_name) < 50 and potential_name != room_id:
  568.                                     anchor_info[“name”] = potential_name
  569.                                     logger.info(f”[尝试{attempt + 1}] 从页面标题解析到主播名字: {potential_name}”)
  570.                         # 格式 3: “ 主播名字正在直播 ”
  571.                         elif “ 正在直播 ” in title:
  572.                             name_from_title = title.replace(“ 正在直播 ”, “”).strip()
  573.                             if name_from_title and len(name_from_title) < 50 and name_from_title != room_id:
  574.                                 anchor_info[“name”] = name_from_title
  575.                                 logger.info(f”[尝试{attempt + 1}] 从 ’ 正在直播 ’ 标题获取到主播名字: {name_from_title}”)
  576.                         # 格式 4: 直接使用标题(如果长度合理)
  577.                         elif len(title) < 50 and title != room_id and not any(word in title.lower() for word in [“douyin”, “live”, “ 直播 ”]):
  578.                             anchor_info[“name”] = title
  579.                             logger.info(f”[尝试{attempt + 1}] 直接使用页面标题作为主播名字: {title}”)
  580.                 except Exception as title_e:
  581.                     logger.debug(f”[尝试{attempt + 1}] 从标题获取名字失败: {title_e}”)
  582.                 # 策略 2: 尝试从页面元素获取(如果标题没有找到合适的名字)
  583.                 if anchor_info[“name”] == f”anchor_{room_id}”:
  584.                     try:
  585.                         logger.info(f”[尝试{attempt + 1}] 尝试从页面元素获取主播名字 …”)
  586.                         # 更新的选择器列表
  587.                         name_selectors = [
  588.                             “[data-e2e=’living-avatar-name’]”,
  589.                             “[data-e2e=’user-info-name’]”,
  590.                             “.webcast-avatar-info__name”,
  591.                             “.live-user-info .name”,
  592.                             “.live-user-name”,
  593.                             “.user-name”,
  594.                             “.anchor-name”,
  595.                             “[class*=’name’]”,
  596.                             “h3”,
  597.                             “.nickname”
  598.                         ]
  599.                         for selector in name_selectors:
  600.                             try:
  601.                                 name_element = page.query_selector(selector)
  602.                                 if name_element:
  603.                                     name_text = name_element.inner_text().strip()
  604.                                     if name_text and len(name_text) < 50 and name_text != room_id and not name_text.isdigit():
  605.                                         anchor_info[“name”] = name_text
  606.                                         logger.info(f”[尝试{attempt + 1}] 使用选择器 {selector} 获取到主播名字: {name_text}”)
  607.                                         break
  608.                             except Exception as sel_e:
  609.                                 logger.debug(f”[尝试{attempt + 1}] 选择器 {selector} 失败: {sel_e}”)
  610.                                 continue
  611.                     except Exception as e:
  612.                         logger.debug(f”[尝试{attempt + 1}] 从页面元素获取名字失败: {e}”)
  613.                 # 策略 3: 从页面 JSON 数据中提取(如果前面都没找到)
  614.                 if anchor_info[“name”] == f”anchor_{room_id}”:
  615.                     try:
  616.                         logger.info(f”[尝试{attempt + 1}] 尝试从页面 JSON 数据获取主播名字 …”)
  617.                         content_text = page.content()
  618.                         # 多种 JSON 字段模式
  619.                         json_patterns = [
  620.                             r'”nickname”\s*:\s*”([^”]+)”‘,
  621.                             r'”displayName”\s*:\s*”([^”]+)”‘,
  622.                             r'”userName”\s*:\s*”([^”]+)”‘,
  623.                             r'”ownerName”\s*:\s*”([^”]+)”‘,
  624.                             r'”anchorName”\s*:\s*”([^”]+)”‘,
  625.                             r'”user_name”\s*:\s*”([^”]+)”‘,
  626.                             r'”anchor_info”[^}]*”nickname”\s*:\s*”([^”]+)”‘
  627.                         ]
  628.                         import re as regex_re
  629.                         for pattern in json_patterns:
  630.                             matches = regex_re.findall(pattern, content_text)
  631.                             for match in matches:
  632.                                 if match and len(match) < 50 and match != room_id and not match.isdigit():
  633.                                     # 过滤掉明显不是名字的内容
  634.                                     if not any(word in match.lower() for word in [‘http’, ‘www’, ‘.com’, ‘live’, ‘stream’]):
  635.                                         anchor_info[“name”] = match
  636.                                         logger.info(f”[尝试{attempt + 1}] 从页面 JSON 数据获取到主播名字: {match} (模式: {pattern})”)
  637.                                         break
  638.                             if anchor_info[“name”] != f”anchor_{room_id}”:
  639.                                 break
  640.                     except Exception as content_e:
  641.                         logger.debug(f”[尝试{attempt + 1}] 从页面内容获取名字失败: {content_e}”)
  642.                 # 策略 4: 最后的降级处理(使用更友好的默认名字)
  643.                 if anchor_info[“name”] == f”anchor_{room_id}”:
  644.                     # 尝试从 room_id 中提取可能的用户名部分
  645.                     if len(room_id) > 8:  # 如果 room_id 足够长,尝试截取前 8 位作为更简洁的标识
  646.                         anchor_info[“name”] = f” 主播{room_id[:8]}”
  647.                     else:
  648.                         anchor_info[“name”] = f” 主播{room_id}”
  649.                     logger.info(f”[尝试{attempt + 1}] 使用降级处理的默认名字: {anchor_info[‘name’]}”)
  650.                 # 检查直播状态
  651.                 stream_urls = []
  652.                 def handle_response(response):
  653.                     url = response.url
  654.                     if ((url.endswith(‘.flv’) or url.endswith(‘.m3u8’)) and
  655.                         not url.endswith(‘.mp4’) and
  656.                         (‘pull-‘ in url or ‘douyincdn.com’ in url)):
  657.                         stream_urls.append(url)
  658.                         logger.info(f”[尝试{attempt + 1}] 捕获到直播流: {url}”)
  659.                 page.on(“response”, handle_response)
  660.                 # 等待更多网络请求
  661.                 logger.info(f”[尝试{attempt + 1}] 等待网络请求 …”)
  662.                 time.sleep(3)
  663.                 # 多种方式检测直播状态
  664.                 anchor_info[“is_live”] = (
  665.                     “ 直播中 ” in content or
  666.                     “ 正在直播 ” in content or
  667.                     “live_no_stream” not in content.lower() and “ 直播 ” in content or
  668.                     “live” in content.lower() or
  669.                     page.query_selector(“.webcast-chatroom___enter-done”) is not None or
  670.                     page.query_selector(“.live-room”) is not None or
  671.                     page.query_selector(“video[src*=’.m3u8′]”) is not None or
  672.                     page.query_selector(“video[src*=’.flv’]”) is not None or
  673.                     page.query_selector(“video[src*=’douyincdn.com’]”) is not None or
  674.                     len(stream_urls) > 0
  675.                 )
  676.                 context.close()
  677.                 logger.info(f”[尝试{attempt + 1}] 最终获取结果 – 主播名字: {anchor_info[‘name’]}, 直播状态: {‘ 在线 ’ if anchor_info[‘is_live’] else ‘ 离线 ’}”)
  678.                 return anchor_info
  679.         except Exception as e:
  680.             logger.error(f” 获取主播信息失败 (尝试 {attempt + 1}): {str(e)}”)
  681.             if attempt < max_retries – 1:
  682.                 time.sleep(2)
  683.             continue
  684.     logger.error(f” 所有 {max_retries} 次尝试均失败,返回默认结果 ”)
  685.     # 最终降级处理
  686.     fallback_name = f” 主播{anchor_id[:8]}” if len(str(anchor_id)) > 8 else f” 主播{anchor_id}”
  687.     return {“is_live”: False, “name”: fallback_name, “title”: “”}
  688. def check_anchor_status(anchor_id, max_retries=2):
  689.     “””
  690.     检查主播是否开播
  691.     :param anchor_id: 主播 ID
  692.     :param max_retries: 最大重试次数
  693.     :return: True(开播)/False(未开播)
  694.     “””
  695.     for attempt in range(max_retries):
  696.         try:
  697.             from playwright.sync_api import sync_playwright
  698.             import random
  699.             with sync_playwright() as p:
  700.                 # 启动浏览器(无头模式)
  701.                 browser = p.chromium.launch(headless=True)
  702.                 context = browser.new_context(
  703.                     user_agent=”Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36″,
  704.                     extra_http_headers={
  705.                         “Referer”: “https://www.douyin.com/”,
  706.                         “Accept-Language”: “zh-CN,zh;q=0.9”
  707.                     },
  708.                     viewport={“width”: 1920, “height”: 1080},
  709.                     java_script_enabled=True
  710.                 )
  711.                 page = context.new_page()
  712.                 # 随机延迟(1- 3 秒),模拟人类操作
  713.                 time.sleep(random.uniform(1, 3))
  714.                 # 访问直播间页面
  715.                 try:
  716.                     # 处理 URL 格式,确保不重复添加域名
  717.                     if anchor_id.startswith(“https://live.douyin.com/”):
  718.                         url = anchor_id
  719.                         room_id = anchor_id.split(“/”)[-1]
  720.                     else:
  721.                         url = f”https://live.douyin.com/{anchor_id}”
  722.                         room_id = anchor_id
  723.                     page.goto(url, timeout=30000, wait_until=”domcontentloaded”)
  724.                     logger.info(f” 成功访问直播间页面: {url}”)
  725.                 except Exception as e:
  726.                     if “Timeout” in str(e):
  727.                         logger.warning(f” 页面加载超时,继续处理 ”)
  728.                     else:
  729.                         logger.error(f” 访问直播间页面失败: {e}”)
  730.                         context.close()
  731.                         continue
  732.                 # 等待页面加载
  733.                 try:
  734.                     page.wait_for_selector(“video, .live-room, .webcast-chatroom”, timeout=10000)
  735.                 except:
  736.                     logger.warning(“ 未找到关键元素,继续处理 ”)
  737.                 # 获取页面内容
  738.                 content = page.content()
  739.                 # 检查直播状态
  740.                 stream_urls = []
  741.                 def handle_response(response):
  742.                     url = response.url
  743.                     if ((url.endswith(‘.flv’) or url.endswith(‘.m3u8’)) and
  744.                         not url.endswith(‘.mp4’) and
  745.                         (‘pull-‘ in url or ‘douyincdn.com’ in url)):
  746.                         stream_urls.append(url)
  747.                         logger.info(f” 捕获到直播流: {url}”)
  748.                 page.on(“response”, handle_response)
  749.                 # 等待更多网络请求
  750.                 time.sleep(3)
  751.                 # 多种方式检测直播状态
  752.                 is_live = (
  753.                     “ 直播中 ” in content or
  754.                     “ 正在直播 ” in content or
  755.                     “ 直播 ” in content or
  756.                     “live” in content.lower() or
  757.                     page.query_selector(“.webcast-chatroom___enter-done”) is not None or
  758.                     page.query_selector(“.live-room”) is not None or
  759.                     page.query_selector(“video[src*=’.m3u8′]”) is not None or
  760.                     page.query_selector(“video[src*=’.flv’]”) is not None or
  761.                     page.query_selector(“video[src*=’douyincdn.com’]”) is not None or
  762.                     len(stream_urls) > 0 or
  763.                     any(“live.douyin.com” in url for url in stream_urls)
  764.                 )
  765.                 context.close()
  766.                 if is_live:
  767.                     logger.info(f” 主播 {anchor_id} 正在直播 ”)
  768.                     if stream_urls:
  769.                         logger.info(f” 捕获到直播流地址: {stream_urls[0]}”)
  770.                 else:
  771.                     logger.info(f” 主播 {anchor_id} 未开播 ”)
  772.                 return is_live
  773.         except Exception as e:
  774.             logger.error(f” 检查主播状态失败 (尝试 {attempt + 1}): {str(e)}”)
  775.             if attempt < max_retries – 1:
  776.                 time.sleep(2)
  777.             continue
  778.     logger.error(f” 所有 {max_retries} 次尝试均失败 ”)
  779.     return False
  780. @app.route(‘/api/monitor’, methods=[‘POST’])
  781. @handle_exceptions
  782. def monitor_live_stream():
  783.     data = request.get_json()
  784.     anchor_id = data.get(‘anchor_id’)
  785.     max_wait_minutes = data.get(‘max_wait’, 5)  # 默认最多等待 5 分钟
  786.     check_interval = data.get(‘interval’, 30)   # 默认每 30 秒检查一次
  787.     logger.info(f” 收到监控请求,主播 ID: {anchor_id}, 最长等待: {max_wait_minutes}分钟, 轮询地址: https://live.douyin.com/{anchor_id}”)
  788.     if not anchor_id:
  789.         logger.warning(“ 无效的主播 ID”)
  790.         return jsonify({
  791.             ‘success’: False,
  792.             ‘message’: ‘ 无效的主播 ID’
  793.         })
  794.     max_checks = (max_wait_minutes * 60) // check_interval
  795.     checks_done = 0
  796.     # 轮询检查主播状态
  797.     while checks_done < max_checks:
  798.         checks_done += 1
  799.         logger.info(f” 第 {checks_done}/{max_checks} 次检查主播 {anchor_id} 状态 ”)
  800.         is_live = check_anchor_status(anchor_id)
  801.         if is_live:
  802.             logger.info(f” 主播 {anchor_id} 正在直播,开始解析直播流地址 ”)
  803.             # 获取直播流地址
  804.             stream_url = get_real_stream_url(f”https://live.douyin.com/{anchor_id}”)
  805.             if stream_url:
  806.                 logger.info(f” 成功获取直播流地址: {stream_url}”)
  807.                 return jsonify({
  808.                     ‘success’: True,
  809.                     ‘status’: ‘live’,
  810.                     ‘streamUrl’: stream_url,
  811.                     ‘checks_performed’: checks_done
  812.                 })
  813.             else:
  814.                 logger.warning(“ 无法解析直播流地址 ”)
  815.                 return jsonify({
  816.                     ‘success’: False,
  817.                     ‘message’: ‘ 无法解析直播流地址 ’,
  818.                     ‘checks_performed’: checks_done
  819.                 })
  820.         else:
  821.             logger.info(f” 主播 {anchor_id} 未开播,等待下一次检查 ”)
  822.             # 如果达到最大检查次数,返回未开播状态
  823.             if checks_done >= max_checks:
  824.                 logger.info(f” 监控超时,主播 {anchor_id} 在 {max_wait_minutes} 分钟内未开播 ”)
  825.                 return jsonify({
  826.                     ‘success’: True,
  827.                     ‘status’: ‘not_live’,
  828.                     ‘message’: f’ 主播在 {max_wait_minutes} 分钟内未开播 ’,
  829.                     ‘checks_performed’: checks_done
  830.                 })
  831.             time.sleep(check_interval)
  832.     logger.warning(“ 监控循环异常结束 ”)
  833.     return jsonify({
  834.         ‘success’: False,
  835.         ‘message’: ‘ 监控异常结束 ’,
  836.         ‘checks_performed’: checks_done
  837.     })
  838. class MultiRoomPoller:
  839.     “”” 多直播间轮询管理器 ”””
  840.     def __init__(self):
  841.         self.polling_rooms = {}  # 存储轮询中的直播间
  842.         self.polling_history = []  # 存储历史轮询记录
  843.         self.lock = threading.Lock()
  844.         self.running = True
  845.         self.max_history_records = 1000  # 最大历史记录数
  846.         self.rooms_file = ‘saved_rooms.json’  # 本地存储文件
  847.         self.history_file = ‘rooms_history.json’  # 历史记录文件
  848.         # 启动时加载已保存的直播间
  849.         self._load_rooms_from_file()
  850.         self._load_history_from_file()
  851.     def add_room(self, room_id, room_url, check_interval=60, auto_record=False):
  852.         “”” 添加直播间到轮询列表 ”””
  853.         with self.lock:
  854.             if room_id not in self.polling_rooms:
  855.                 # 不再在这里添加历史记录,等待轮询线程获取到真实主播名字后再添加
  856.                 self.polling_rooms[room_id] = {
  857.                     ‘room_url’: room_url,
  858.                     ‘room_id’: room_id,
  859.                     ‘check_interval’: check_interval,
  860.                     ‘auto_record’: auto_record,
  861.                     ‘status’: ‘waiting’,  # waiting, checking, live, offline, paused
  862.                     ‘last_check’: None,
  863.                     ‘stream_url’: None,
  864.                     ‘recording_session_id’: None,
  865.                     ‘thread’: None,
  866.                     ‘anchor_name’: f’anchor_{room_id}’,  # 新增:主播名字
  867.                     ‘live_title’: ”,  # 新增:直播标题
  868.                     ‘added_time’: datetime.now(),  # 新增:添加时间
  869.                     ‘history_added’: False,  # 新增:标记是否已添加历史记录
  870.                     ‘online_count’: 0,  # 新增:在线人数
  871.                     ‘viewer_count_text’: ”  # 新增:观看人数文本
  872.                 }
  873.                 # 启动轮询线程
  874.                 thread = threading.Thread(
  875.                     target=self._poll_room,
  876.                     args=(room_id,),
  877.                     daemon=True
  878.                 )
  879.                 thread.start()
  880.                 self.polling_rooms[room_id][‘thread’] = thread
  881.                 logger.info(f” 已添加直播间 {room_id} 到轮询列表 ”)
  882.                 # 保存到本地文件
  883.                 self._save_rooms_to_file()
  884.                 return True
  885.             else:
  886.                 logger.warning(f” 直播间 {room_id} 已在轮询列表中 ”)
  887.                 return False
  888.     def remove_room(self, room_id):
  889.         “”” 从轮询列表移除直播间 ”””
  890.         with self.lock:
  891.             if room_id in self.polling_rooms:
  892.                 room_info = self.polling_rooms[room_id]
  893.                 # 记录到历史
  894.                 self._add_to_history(
  895.                     room_id,
  896.                     room_info[‘room_url’],
  897.                     ”,
  898.                     ”,
  899.                     room_info.get(‘anchor_name’, f’anchor_{room_id}’)
  900.                 )
  901.                 # 停止录制(如果正在录制)
  902.                 if self.polling_rooms[room_id][‘recording_session_id’]:
  903.                     self._stop_recording(room_id)
  904.                 # 标记线程停止
  905.                 self.polling_rooms[room_id][‘status’] = ‘stopped’
  906.                 del self.polling_rooms[room_id]
  907.                 # 保存到本地文件
  908.                 self._save_rooms_to_file()
  909.                 logger.info(f” 已从轮询列表移除直播间 {room_id}”)
  910.                 return True
  911.             return False
  912.     def pause_room(self, room_id):
  913.         “”” 暂停指定直播间的轮询 ”””
  914.         with self.lock:
  915.             if room_id in self.polling_rooms:
  916.                 # 如果已经在暂停状态,返回 False
  917.                 if self.polling_rooms[room_id][‘status’] == ‘paused’:
  918.                     return False
  919.                 # 更新状态为暂停
  920.                 self.polling_rooms[room_id][‘status’] = ‘paused’
  921.                 logger.info(f” 已暂停直播间 {room_id} 的轮询 ”)
  922.                 return True
  923.             return False
  924.     def resume_room(self, room_id):
  925.         “”” 恢复指定直播间的轮询 ”””
  926.         with self.lock:
  927.             if room_id in self.polling_rooms:
  928.                 # 如果不在暂停状态,返回 False
  929.                 if self.polling_rooms[room_id][‘status’] != ‘paused’:
  930.                     return False
  931.                 # 更新状态为等待
  932.                 self.polling_rooms[room_id][‘status’] = ‘waiting’
  933.                 logger.info(f” 已恢复直播间 {room_id} 的轮询 ”)
  934.                 return True
  935.             return False
  936.     def _poll_room(self, room_id):
  937.         “”” 单个直播间轮询逻辑 ”””
  938.         while self.running:
  939.             try:
  940.                 with self.lock:
  941.                     if room_id not in self.polling_rooms:
  942.                         break
  943.                     room_info = self.polling_rooms[room_id]
  944.                     # 检查是否暂停
  945.                     if room_info[‘status’] == ‘paused’:
  946.                         # 如果暂停,等待一段时间后继续检查
  947.                         time.sleep(5)
  948.                         continue
  949.                     if room_info[‘status’] == ‘stopped’:
  950.                         break
  951.                 # 更新状态为检查中
  952.                 with self.lock:
  953.                     self.polling_rooms[room_id][‘status’] = ‘checking’
  954.                     self.polling_rooms[room_id][‘last_check’] = datetime.now()
  955.                 # 检查直播状态并获取主播信息
  956.                 anchor_info = get_anchor_info(room_info[‘room_id’])
  957.                 is_live = anchor_info[‘is_live’]
  958.                 # 获取直播间详细信息(包括在线人数)
  959.                 room_detail_info = {‘online_count’: 0, ‘viewer_count_text’: ”}
  960.                 if is_live:
  961.                     try:
  962.                         # 调用 get_live_room_info 获取在线人数信息
  963.                         room_detail_info = get_live_room_info(room_info[‘room_url’])
  964.                         logger.info(f” 直播间 {room_id} 在线人数: {room_detail_info.get(‘online_count’, 0)}”)
  965.                     except Exception as e:
  966.                         logger.warning(f” 获取直播间 {room_id} 在线人数失败: {e}”)
  967.                 # 更新主播信息和在线人数
  968.                 with self.lock:
  969.                     self.polling_rooms[room_id][‘anchor_name’] = anchor_info[‘name’]
  970.                     self.polling_rooms[room_id][‘live_title’] = anchor_info[‘title’]
  971.                     self.polling_rooms[room_id][‘online_count’] = room_detail_info.get(‘online_count’, 0)
  972.                     self.polling_rooms[room_id][‘viewer_count_text’] = room_detail_info.get(‘viewer_count_text’, ”)
  973.                     # 如果还没有添加历史记录,现在添加一条记录
  974.                     if not self.polling_rooms[room_id].get(‘history_added’, False):
  975.                         self._add_to_history(
  976.                             room_id,
  977.                             room_info[‘room_url’],
  978.                             ”,
  979.                             ”,
  980.                             anchor_info[‘name’]
  981.                         )
  982.                         self.polling_rooms[room_id][‘history_added’] = True
  983.                 if is_live:
  984.                     logger.info(f” 检测到直播间 {room_id} 正在直播 ”)
  985.                     # 记录状态变化到历史(如果之前不是直播状态)
  986.                     # 简化版:不记录状态变化
  987.                     # 解析直播流地址
  988.                     stream_url = get_real_stream_url(room_info[‘room_url’])
  989.                     if stream_url:
  990.                         with self.lock:
  991.                             self.polling_rooms[room_id][‘status’] = ‘live’
  992.                             self.polling_rooms[room_id][‘stream_url’] = stream_url
  993.                         # 如果启用自动录制且未在录制
  994.                         if (room_info[‘auto_record’] and
  995.                             not room_info[‘recording_session_id’]):
  996.                             self._start_recording(room_id, stream_url)
  997.                             # 简化版:不记录自动录制开始
  998.                     else:
  999.                         logger.warning(f” 直播间 {room_id} 在线但无法获取流地址 ”)
  1000.                         with self.lock:
  1001.                             old_status = self.polling_rooms[room_id][‘status’]
  1002.                             self.polling_rooms[room_id][‘status’] = ‘live_no_stream’
  1003.                             # 简化版:不记录状态变化
  1004.                         # 如果之前在录制,停止录制(直播结束无流)
  1005.                         if room_info[‘recording_session_id’]:
  1006.                             self._stop_recording(room_id)
  1007.                             logger.info(f” 直播间 {room_id} 直播结束无流,已停止录制 ”)
  1008.                             # 简化版:不记录停止录制
  1009.                 else:
  1010.                     # 直播间离线
  1011.                     with self.lock:
  1012.                         old_status = self.polling_rooms[room_id][‘status’]
  1013.                         self.polling_rooms[room_id][‘status’] = ‘offline’
  1014.                         self.polling_rooms[room_id][‘stream_url’] = None
  1015.                         # 简化版:不记录状态变化
  1016.                     # 如果之前在录制,停止录制
  1017.                     if room_info[‘recording_session_id’]:
  1018.                         self._stop_recording(room_id)
  1019.                         logger.info(f” 直播间 {room_id} 离线,已停止录制 ”)
  1020.                         # 简化版:不记录停止录制
  1021.                 # 等待下次检查
  1022.                 time.sleep(room_info[‘check_interval’])
  1023.             except Exception as e:
  1024.                 logger.error(f” 轮询直播间 {room_id} 异常: {str(e)}”)
  1025.                 with self.lock:
  1026.                     if room_id in self.polling_rooms:
  1027.                         self.polling_rooms[room_id][‘status’] = ‘error’
  1028.                 time.sleep(30)  # 出错时等待 30 秒后重试
  1029.     def _start_recording(self, room_id, stream_url):
  1030.         “”” 启动录制 ”””
  1031.         try:
  1032.             # 获取主播名字用于文件命名
  1033.             with self.lock:
  1034.                 anchor_name = self.polling_rooms[room_id].get(‘anchor_name’, f’anchor_{room_id}’)
  1035.             # 清理文件名中的非法字符
  1036.             safe_anchor_name = re.sub(r'[<>:”/\|?*]’, ‘_’, anchor_name)
  1037.             session_id = f”auto_record_{room_id}_{int(time.time())}”
  1038.             timestamp = datetime.now().strftime(‘%Y%m%d_%H%M%S’)
  1039.             # 使用主播名字命名文件
  1040.             output_path = f”recordings/{safe_anchor_name}_{timestamp}.mp4″
  1041.             # 启动录制线程
  1042.             thread = threading.Thread(
  1043.                 target=record_stream,
  1044.                 args=(stream_url, output_path, session_id),
  1045.                 daemon=True
  1046.             )
  1047.             thread.start()
  1048.             with self.lock:
  1049.                 self.polling_rooms[room_id][‘recording_session_id’] = session_id
  1050.             logger.info(f” 已为主播 {anchor_name} (房间 {room_id}) 启动自动录制,会话 ID: {session_id},文件: {output_path}”)
  1051.         except Exception as e:
  1052.             logger.error(f” 启动直播间 {room_id} 录制失败: {str(e)}”)
  1053.     def _stop_recording(self, room_id):
  1054.         “”” 停止录制 ”””
  1055.         try:
  1056.             with self.lock:
  1057.                 session_id = self.polling_rooms[room_id][‘recording_session_id’]
  1058.                 if session_id:
  1059.                     self.polling_rooms[room_id][‘recording_session_id’] = None
  1060.             if session_id:
  1061.                 # 停止录制会话
  1062.                 with recording_lock:
  1063.                     if session_id in recording_sessions:
  1064.                         session = recording_sessions[session_id]
  1065.                         if session[‘process’]:
  1066.                             session[‘process’].terminate()
  1067.                         session[‘status’] = ‘stopped’
  1068.                         session[‘end_time’] = datetime.now()
  1069.                 logger.info(f” 已停止直播间 {room_id} 的录制,会话 ID: {session_id}”)
  1070.         except Exception as e:
  1071.             logger.error(f” 停止直播间 {room_id} 录制失败: {str(e)}”)
  1072.     def get_status(self):
  1073.         “”” 获取所有轮询状态 ”””
  1074.         with self.lock:
  1075.             # 过滤掉不能 JSON 序列化的对象(如 Thread)
  1076.             status = {}
  1077.             for room_id, room_info in self.polling_rooms.items():
  1078.                 status[room_id] = {
  1079.                     ‘room_url’: room_info[‘room_url’],
  1080.                     ‘room_id’: room_info[‘room_id’],
  1081.                     ‘check_interval’: room_info[‘check_interval’],
  1082.                     ‘auto_record’: room_info[‘auto_record’],
  1083.                     ‘status’: room_info[‘status’],
  1084.                     ‘last_check’: room_info[‘last_check’].isoformat() if room_info[‘last_check’] else None,
  1085.                     ‘stream_url’: room_info[‘stream_url’],
  1086.                     ‘recording_session_id’: room_info[‘recording_session_id’],
  1087.                     ‘anchor_name’: room_info.get(‘anchor_name’, f’anchor_{room_id}’),  # 新增:主播名字
  1088.                     ‘live_title’: room_info.get(‘live_title’, ”),  # 新增:直播标题
  1089.                     ‘added_time’: room_info.get(‘added_time’).isoformat() if room_info.get(‘added_time’) else None,  # 新增:添加时间
  1090.                     ‘online_count’: room_info.get(‘online_count’, 0),  # 新增:在线人数
  1091.                     ‘viewer_count_text’: room_info.get(‘viewer_count_text’, ”)  # 新增:观看人数文本
  1092.                     # 注意:我们不包含 ‘thread’ 字段,因为它不能 JSON 序列化
  1093.                 }
  1094.             return status
  1095.     def _add_to_history(self, room_id, room_url, action, description, anchor_name=None):
  1096.         “”” 添加记录到历史(简化版,带去重功能)”””
  1097.         # 获取主播名字,优先使用参数,其次从房间信息中获取
  1098.         if not anchor_name:
  1099.             with self.lock:
  1100.                 if room_id in self.polling_rooms:
  1101.                     anchor_name = self.polling_rooms[room_id].get(‘anchor_name’, f’anchor_{room_id}’)
  1102.                 else:
  1103.                     anchor_name = f’anchor_{room_id}’
  1104.         # 检查是否已存在相同的链接(去重)
  1105.         existing_urls = {record[‘room_url’] for record in self.polling_history}
  1106.         if room_url in existing_urls:
  1107.             logger.info(f” 历史记录去重: 链接 {room_url} 已存在,跳过添加 ”)
  1108.             return
  1109.         history_record = {
  1110.             ‘id’: f”{room_id}_{int(time.time()*1000)}”,  # 唯一 ID
  1111.             ‘anchor_name’: anchor_name,
  1112.             ‘room_url’: room_url,
  1113.             ‘timestamp’: datetime.now().isoformat(),
  1114.             ‘date’: datetime.now().strftime(‘%Y-%m-%d’),
  1115.             ‘time’: datetime.now().strftime(‘%H:%M:%S’)
  1116.         }
  1117.         # 添加到历史列表的开头(最新的在前面)
  1118.         self.polling_history.insert(0, history_record)
  1119.         # 保持历史记录数量在限制内
  1120.         if len(self.polling_history) > self.max_history_records:
  1121.             self.polling_history = self.polling_history[:self.max_history_records]
  1122.         # 保存历史记录到文件
  1123.         self._save_history_to_file()
  1124.         logger.info(f” 历史记录: {description} (房间 {room_id}),主播: {anchor_name}”)
  1125.     def get_history(self, limit=50, room_id=None, action=None):
  1126.         “”” 获取历史记录 ”””
  1127.         with self.lock:
  1128.             history = self.polling_history.copy()
  1129.         # 限制返回数量
  1130.         return history[:limit]
  1131.     def _save_rooms_to_file(self):
  1132.         “”” 保存直播间列表到文件 ”””
  1133.         try:
  1134.             rooms_data = {}
  1135.             for room_id, room_info in self.polling_rooms.items():
  1136.                 rooms_data[room_id] = {
  1137.                     ‘room_url’: room_info[‘room_url’],
  1138.                     ‘check_interval’: room_info[‘check_interval’],
  1139.                     ‘auto_record’: room_info[‘auto_record’],
  1140.                     ‘anchor_name’: room_info.get(‘anchor_name’, f’anchor_{room_id}’),
  1141.                     ‘added_time’: room_info[‘added_time’].isoformat() if room_info.get(‘added_time’) else datetime.now().isoformat()
  1142.                 }
  1143.             with open(self.rooms_file, ‘w’, encoding=’utf-8′) as f:
  1144.                 json.dump(rooms_data, f, ensure_ascii=False, indent=2)
  1145.             logger.info(f” 已保存 {len(rooms_data)} 个直播间到 {self.rooms_file}”)
  1146.         except Exception as e:
  1147.             logger.error(f” 保存直播间列表失败: {str(e)}”)
  1148.     def _load_rooms_from_file(self):
  1149.         “”” 从文件加载直播间列表 ”””
  1150.         try:
  1151.             if os.path.exists(self.rooms_file):
  1152.                 with open(self.rooms_file, ‘r’, encoding=’utf-8′) as f:
  1153.                     rooms_data = json.load(f)
  1154.                 for room_id, room_info in rooms_data.items():
  1155.                     # 使用加载的数据创建直播间信息
  1156.                     self.polling_rooms[room_id] = {
  1157.                         ‘room_url’: room_info[‘room_url’],
  1158.                         ‘room_id’: room_id,
  1159.                         ‘check_interval’: room_info.get(‘check_interval’, 60),
  1160.                         ‘auto_record’: room_info.get(‘auto_record’, False),
  1161.                         ‘status’: ‘waiting’,
  1162.                         ‘last_check’: None,
  1163.                         ‘stream_url’: None,
  1164.                         ‘recording_session_id’: None,
  1165.                         ‘thread’: None,
  1166.                         ‘anchor_name’: room_info.get(‘anchor_name’, f’anchor_{room_id}’),
  1167.                         ‘live_title’: ”,
  1168.                         ‘added_time’: datetime.fromisoformat(room_info.get(‘added_time’, datetime.now().isoformat())),
  1169.                         ‘history_added’: False,  # 加载的房间也需要添加历史记录(如果能获取到真实主播名字)
  1170.                         ‘online_count’: room_info.get(‘online_count’, 0),  # 新增:在线人数
  1171.                         ‘viewer_count_text’: room_info.get(‘viewer_count_text’, ”)  # 新增:观看人数文本
  1172.                     }
  1173.                     # 启动轮询线程
  1174.                     thread = threading.Thread(
  1175.                         target=self._poll_room,
  1176.                         args=(room_id,),
  1177.                         daemon=True
  1178.                     )
  1179.                     thread.start()
  1180.                     self.polling_rooms[room_id][‘thread’] = thread
  1181.                 logger.info(f” 从 {self.rooms_file} 加载了 {len(rooms_data)} 个直播间 ”)
  1182.             else:
  1183.                 logger.info(f” 直播间配置文件 {self.rooms_file} 不存在,将创建新文件 ”)
  1184.         except Exception as e:
  1185.             logger.error(f” 加载直播间列表失败: {str(e)}”)
  1186.     def _save_history_to_file(self):
  1187.         “”” 保存历史记录到文件 ”””
  1188.         try:
  1189.             with open(self.history_file, ‘w’, encoding=’utf-8′) as f:
  1190.                 json.dump(self.polling_history, f, ensure_ascii=False, indent=2)
  1191.             logger.debug(f” 已保存历史记录到 {self.history_file}”)
  1192.         except Exception as e:
  1193.             logger.error(f” 保存历史记录失败: {str(e)}”)
  1194.     def _load_history_from_file(self):
  1195.         “”” 从文件加载历史记录(带去重功能)”””
  1196.         try:
  1197.             if os.path.exists(self.history_file):
  1198.                 with open(self.history_file, ‘r’, encoding=’utf-8′) as f:
  1199.                     raw_history = json.load(f)
  1200.                 # 去重处理:根据 room_url 去重,保留最新的记录
  1201.                 seen_urls = set()
  1202.                 deduped_history = []
  1203.                 for record in raw_history:
  1204.                     room_url = record.get(‘room_url’, ”)
  1205.                     if room_url not in seen_urls:
  1206.                         seen_urls.add(room_url)
  1207.                         deduped_history.append(record)
  1208.                     else:
  1209.                         logger.debug(f” 去重: 跳过重复链接 {room_url}”)
  1210.                 self.polling_history = deduped_history
  1211.                 # 如果去重后数量有变化,保存文件
  1212.                 if len(deduped_history) != len(raw_history):
  1213.                     logger.info(f” 历史记录去重: 从 {len(raw_history)} 条去重到 {len(deduped_history)} 条 ”)
  1214.                     self._save_history_to_file()
  1215.                 logger.info(f” 从 {self.history_file} 加载了 {len(self.polling_history)} 条历史记录 ”)
  1216.             else:
  1217.                 logger.info(f” 历史记录文件 {self.history_file} 不存在,将创建新文件 ”)
  1218.         except Exception as e:
  1219.             logger.error(f” 加载历史记录失败: {str(e)}”)
  1220.     def stop_all(self):
  1221.         “”” 停止所有轮询 ”””
  1222.         self.running = False
  1223.         with self.lock:
  1224.             for room_id in list(self.polling_rooms.keys()):
  1225.                 self.remove_room(room_id)
  1226. # 全局轮询管理器实例
  1227. multi_poller = MultiRoomPoller()
  1228. def record_stream(stream_url, output_path, session_id):
  1229.     “””
  1230.     使用 FFmpeg 录制直播流(支持分段录制)
  1231.     :param stream_url: 直播流地址
  1232.     :param output_path: 输出文件路径(不含分段序号)
  1233.     :param session_id: 录制会话 ID
  1234.     “””
  1235.     try:
  1236.         logger.info(f” 开始录制会话 {session_id}: {stream_url}”)
  1237.         # 创建录制目录
  1238.         os.makedirs(os.path.dirname(output_path), exist_ok=True)
  1239.         # 更新录制会话状态
  1240.         with recording_lock:
  1241.             recording_sessions[session_id] = {
  1242.                 ‘process’: None,
  1243.                 ‘output_path’: output_path,
  1244.                 ‘start_time’: datetime.now(),
  1245.                 ‘stream_url’: stream_url,
  1246.                 ‘status’: ‘recording’,
  1247.                 ‘segments’: [],
  1248.                 ‘current_segment’: 0
  1249.             }
  1250.         # 生成分段文件名模板
  1251.         base_name = output_path.rsplit(‘.’, 1)[0]
  1252.         segment_template = f”{base_name}_part%03d.mp4″
  1253.         logger.info(f” 录制会话 {session_id} 输出路径: {output_path}”)
  1254.         logger.info(f” 录制会话 {session_id} 分段模板: {segment_template}”)
  1255.         # 构建 FFmpeg 命令 – 使用正确的分段格式
  1256.         if stream_url.endswith(‘.m3u8’):
  1257.             cmd = [
  1258.                 ‘ffmpeg’,
  1259.                 ‘-i’, stream_url,
  1260.                 ‘-c’, ‘copy’,  # 复制流,不重新编码
  1261.                 ‘-bsf:a’, ‘aac_adtstoasc’,  # 音频流修复
  1262.                 ‘-f’, ‘segment’,  # 使用分段格式
  1263.                 ‘-segment_time’, ‘1800’,  # 30 分钟分段
  1264.                 ‘-segment_format’, ‘mp4’,  # 分段格式为 MP4
  1265.                 ‘-reset_timestamps’, ‘1’,  # 重置时间戳
  1266.                 ‘-segment_list_flags’, ‘live’,  # 实时分段列表
  1267.                 segment_template  # 分段文件名模板
  1268.             ]
  1269.         else:
  1270.             cmd = [
  1271.                 ‘ffmpeg’,
  1272.                 ‘-i’, stream_url,
  1273.                 ‘-c’, ‘copy’,  # 复制流,不重新编码
  1274.                 ‘-f’, ‘segment’,  # 使用分段格式
  1275.                 ‘-segment_time’, ‘1800’,  # 30 分钟分段
  1276.                 ‘-segment_format’, ‘mp4’,  # 分段格式为 MP4
  1277.                 ‘-reset_timestamps’, ‘1’,  # 重置时间戳
  1278.                 ‘-segment_list_flags’, ‘live’,  # 实时分段列表
  1279.                 segment_template  # 分段文件名模板
  1280.             ]
  1281.         logger.info(f”FFmpeg 命令: {‘ ‘.join(cmd)}”)
  1282.         # 执行录制
  1283.         process = subprocess.Popen(
  1284.             cmd,
  1285.             stdout=subprocess.PIPE,
  1286.             stderr=subprocess.PIPE,
  1287.             universal_newlines=True
  1288.         )
  1289.         # 更新录制会话状态
  1290.         with recording_lock:
  1291.             recording_sessions[session_id][‘process’] = process
  1292.         # 等待进程结束或手动停止
  1293.         stdout, stderr = process.communicate()
  1294.         # 更新最终状态
  1295.         with recording_lock:
  1296.             if session_id in recording_sessions:
  1297.                 if process.returncode == 0:
  1298.                     recording_sessions[session_id][‘status’] = ‘completed’
  1299.                     logger.info(f” 录制会话 {session_id} 成功完成 ”)
  1300.                 else:
  1301.                     recording_sessions[session_id][‘status’] = ‘failed’
  1302.                     recording_sessions[session_id][‘error’] = stderr
  1303.                     logger.error(f” 录制会话 {session_id} 失败: {stderr}”)
  1304.                 recording_sessions[session_id][‘end_time’] = datetime.now()
  1305.     except Exception as e:
  1306.         logger.error(f” 录制会话 {session_id} 异常: {str(e)}”)
  1307.         with recording_lock:
  1308.             if session_id in recording_sessions:
  1309.                 recording_sessions[session_id][‘status’] = ‘failed’
  1310.                 recording_sessions[session_id][‘error’] = str(e)
  1311.                 recording_sessions[session_id][‘end_time’] = datetime.now()
  1312. @app.route(‘/api/record/start’, methods=[‘POST’])
  1313. @handle_exceptions
  1314. def start_recording():
  1315.     “””
  1316.     开始录制直播流
  1317.     “””
  1318.     data = request.get_json()
  1319.     stream_url = data.get(‘stream_url’)
  1320.     session_id = data.get(‘session_id’) or f”recording_{int(time.time())}”
  1321.     anchor_name = data.get(‘anchor_name’, ‘unknown_anchor’)  # 新增:主播名字参数
  1322.     if not stream_url:
  1323.         return jsonify({
  1324.             ‘success’: False,
  1325.             ‘message’: ‘ 缺少直播流地址 ’
  1326.         })
  1327.     # 检查是否已在录制
  1328.     with recording_lock:
  1329.         if session_id in recording_sessions and recording_sessions[session_id][‘status’] == ‘recording’:
  1330.             return jsonify({
  1331.                 ‘success’: False,
  1332.                 ‘message’: ‘ 该会话已在录制中 ’
  1333.             })
  1334.     # 清理文件名中的非法字符
  1335.     safe_anchor_name = re.sub(r'[<>:”/\\|?*]’, ‘_’, anchor_name)
  1336.     # 生成输出文件路径(使用主播名字)
  1337.     timestamp = datetime.now().strftime(‘%Y%m%d_%H%M%S’)
  1338.     output_path = f”recordings/{safe_anchor_name}_{timestamp}.mp4″
  1339.     # 启动录制线程
  1340.     thread = threading.Thread(
  1341.         target=record_stream,
  1342.         args=(stream_url, output_path, session_id),
  1343.         daemon=True
  1344.     )
  1345.     thread.start()
  1346.     return jsonify({
  1347.         ‘success’: True,
  1348.         ‘session_id’: session_id,
  1349.         ‘output_path’: output_path,
  1350.         ‘message’: ‘ 录制已开始 ’
  1351.     })
  1352. @app.route(‘/api/record/stop’, methods=[‘POST’])
  1353. @handle_exceptions
  1354. def stop_recording():
  1355.     “””
  1356.     停止录制
  1357.     “””
  1358.     data = request.get_json()
  1359.     session_id = data.get(‘session_id’)
  1360.     if not session_id:
  1361.         return jsonify({
  1362.             ‘success’: False,
  1363.             ‘message’: ‘ 缺少会话 ID’
  1364.         })
  1365.     with recording_lock:
  1366.         if session_id not in recording_sessions:
  1367.             return jsonify({
  1368.                 ‘success’: False,
  1369.                 ‘message’: ‘ 找不到录制会话 ’
  1370.             })
  1371.         session = recording_sessions[session_id]
  1372.         if session[‘status’] != ‘recording’:
  1373.             return jsonify({
  1374.                 ‘success’: False,
  1375.                 ‘message’: f’ 会话状态为 {session[“status”]}, 无法停止 ’
  1376.             })
  1377.         # 终止 FFmpeg 进程
  1378.         try:
  1379.             session[‘process’].terminate()
  1380.             session[‘status’] = ‘stopped’
  1381.             session[‘end_time’] = datetime.now()
  1382.             logger.info(f” 已停止录制会话 {session_id}”)
  1383.         except Exception as e:
  1384.             logger.error(f” 停止录制会话 {session_id} 失败: {str(e)}”)
  1385.             return jsonify({
  1386.                 ‘success’: False,
  1387.                 ‘message’: f’ 停止录制失败: {str(e)}’
  1388.             })
  1389.     return jsonify({
  1390.         ‘success’: True,
  1391.         ‘message’: ‘ 录制已停止 ’
  1392.     })
  1393. @app.route(‘/api/get_current_stream’, methods=[‘GET’])
  1394. @handle_exceptions
  1395. def get_current_stream():
  1396.     “””
  1397.     获取当前最新的直播流地址
  1398.     “””
  1399.     import os
  1400.     stream_file = ‘current_stream.txt’
  1401.     if os.path.exists(stream_file):
  1402.         try:
  1403.             with open(stream_file, ‘r’, encoding=’utf-8′) as f:
  1404.                 stream_url = f.read().strip()
  1405.             if stream_url:
  1406.                 logger.info(f” 读取到当前直播流地址: {stream_url}”)
  1407.                 return jsonify({
  1408.                     ‘success’: True,
  1409.                     ‘stream_url’: stream_url,
  1410.                     ‘message’: ‘ 成功获取直播流地址 ’
  1411.                 })
  1412.             else:
  1413.                 return jsonify({
  1414.                     ‘success’: False,
  1415.                     ‘message’: ‘ 直播流文件为空 ’
  1416.                 })
  1417.         except Exception as e:
  1418.             logger.error(f” 读取直播流文件失败: {str(e)}”)
  1419.             return jsonify({
  1420.                 ‘success’: False,
  1421.                 ‘message’: f’ 读取文件失败: {str(e)}’
  1422.             })
  1423.     else:
  1424.         return jsonify({
  1425.             ‘success’: False,
  1426.             ‘message’: ‘ 直播流文件不存在 ’
  1427.         })
  1428. @app.route(‘/api/record/split’, methods=[‘POST’])
  1429. @handle_exceptions
  1430. def split_recording():
  1431.     “””
  1432.     手动分段录制
  1433.     “””
  1434.     data = request.get_json()
  1435.     session_id = data.get(‘session_id’)
  1436.     if not session_id:
  1437.         return jsonify({
  1438.             ‘success’: False,
  1439.             ‘message’: ‘ 缺少会话 ID’
  1440.         })
  1441.     with recording_lock:
  1442.         if session_id not in recording_sessions:
  1443.             return jsonify({
  1444.                 ‘success’: False,
  1445.                 ‘message’: ‘ 找不到录制会话 ’
  1446.             })
  1447.         session = recording_sessions[session_id]
  1448.         if session[‘status’] != ‘recording’:
  1449.             return jsonify({
  1450.                 ‘success’: False,
  1451.                 ‘message’: f’ 会话状态为 {session[“status”]}, 无法分段 ’
  1452.             })
  1453.         # 向 FFmpeg 进程发送分割信号
  1454.         try:
  1455.             # FFmpeg 的 segment 功能会自动创建新分段,这里只需记录操作
  1456.             session[‘current_segment’] += 1
  1457.             logger.info(f” 已为录制会话 {session_id} 创建新分段 {session[‘current_segment’]}”)
  1458.             return jsonify({
  1459.                 ‘success’: True,
  1460.                 ‘message’: f’ 已创建新分段 {session[“current_segment”]}’,
  1461.                 ‘segment_number’: session[‘current_segment’]
  1462.             })
  1463.         except Exception as e:
  1464.             logger.error(f” 分段录制会话 {session_id} 失败: {str(e)}”)
  1465.             return jsonify({
  1466.                 ‘success’: False,
  1467.                 ‘message’: f’ 分段失败: {str(e)}’
  1468.             })
  1469. @app.route(‘/api/poll’, methods=[‘POST’])
  1470. @handle_exceptions
  1471. def poll_live_stream():
  1472.     data = request.get_json()
  1473.     live_url = data.get(‘live_url’)
  1474.     logger.info(f” 收到轮询请求,直播间地址: {live_url}”)
  1475.     # 检查 URL 是否有效
  1476.     if not live_url:
  1477.         logger.warning(“ 轮询请求中 URL 为空 ”)
  1478.         return jsonify({
  1479.             ‘success’: False,
  1480.             ‘message’: ‘ 直播间地址为空 ’
  1481.         })
  1482.     # 处理不同格式的输入
  1483.     processed_url = live_url.strip()
  1484.     # 1. 检查是否是纯数字(主播 ID)
  1485.     if re.match(r’^\d+$’, processed_url):
  1486.         logger.info(f” 检测到主播 ID 格式: {processed_url}”)
  1487.         room_id = processed_url
  1488.         full_url = f”https://live.douyin.com/{room_id}”
  1489.     # 2. 检查是否是完整的抖音直播 URL
  1490.     elif “douyin.com” in processed_url:
  1491.         logger.info(f” 检测到抖音 URL 格式: {processed_url}”)
  1492.         # 提取房间号
  1493.         room_id_match = re.search(r’live\.douyin\.com\/([^/?]+)’, processed_url)
  1494.         if room_id_match:
  1495.             room_id = room_id_match.group(1)
  1496.             full_url = f”https://live.douyin.com/{room_id}”
  1497.         else:
  1498.             # 尝试从 URL 路径中提取最后一部分
  1499.             url_parts = processed_url.split(‘/’)
  1500.             room_id = url_parts[-1] or url_parts[-2]
  1501.             full_url = processed_url
  1502.     # 3. 其他格式(可能是短链接或其他标识符)
  1503.     else:
  1504.         logger.info(f” 未识别的 URL 格式,尝试直接使用: {processed_url}”)
  1505.         room_id = processed_url
  1506.         full_url = processed_url
  1507.     logger.info(f” 处理后的房间 ID: {room_id}, 完整 URL: {full_url}”)
  1508.     # 检查主播是否开播
  1509.     try:
  1510.         is_live = check_anchor_status(room_id)
  1511.         # 如果检测为未开播,但用户确认已开播,增加额外检查
  1512.         if not is_live:
  1513.             logger.warning(f” 初步检测主播 {room_id} 未开播,进行二次验证 ”)
  1514.             # 增加等待时间
  1515.             time.sleep(5)
  1516.             # 再次检查
  1517.             is_live = check_anchor_status(room_id)
  1518.         # 如果检测到开播,尝试解析直播流地址
  1519.         stream_url = None
  1520.         if is_live:
  1521.             logger.info(f” 检测到主播 {room_id} 正在直播,开始解析直播流地址 ”)
  1522.             try:
  1523.                 stream_url = get_real_stream_url(full_url)
  1524.                 if stream_url:
  1525.                     logger.info(f” 成功解析直播流地址: {stream_url}”)
  1526.                 else:
  1527.                     logger.warning(f” 无法解析直播流地址,但主播确实在直播 ”)
  1528.             except Exception as parse_error:
  1529.                 logger.error(f” 解析直播流地址异常: {str(parse_error)}”)
  1530.                 # 解析失败不影响轮询结果,只是记录日志
  1531.         logger.info(f” 最终轮询结果: 主播 {room_id} {‘ 正在直播 ’ if is_live else ‘ 未开播 ’}”)
  1532.         # 按照 API 接口规范返回数据
  1533.         response_data = {
  1534.             ‘success’: True,
  1535.             ‘message’: ‘ 轮询请求已处理 ’,
  1536.             ‘data’: {
  1537.                 ‘live_url’: live_url,
  1538.                 ‘is_live’: is_live,
  1539.                 ‘room_id’: room_id,
  1540.                 ‘full_url’: full_url
  1541.             }
  1542.         }
  1543.         # 如果解析到了直播流地址,添加到返回数据中
  1544.         if stream_url:
  1545.             response_data[‘data’][‘stream_url’] = stream_url
  1546.         return jsonify(response_data)
  1547.     except Exception as e:
  1548.         logger.error(f” 轮询处理异常: {str(e)}”)
  1549.         return jsonify({
  1550.             ‘success’: False,
  1551.             ‘message’: f’ 轮询处理异常: {str(e)}’,
  1552.             ‘live_url’: live_url
  1553.         })
  1554. @app.route(‘/api/record/status’, methods=[‘GET’])
  1555. @handle_exceptions
  1556. def get_recording_status():
  1557.     “””
  1558.     获取录制状态
  1559.     “””
  1560.     session_id = request.args.get(‘session_id’)
  1561.     if session_id:
  1562.         with recording_lock:
  1563.             if session_id in recording_sessions:
  1564.                 session = recording_sessions[session_id]
  1565.                 return jsonify({
  1566.                     ‘success’: True,
  1567.                     ‘session_id’: session_id,
  1568.                     ‘status’: session[‘status’],
  1569.                     ‘output_path’: session.get(‘output_path’),
  1570.                     ‘start_time’: session.get(‘start_time’),
  1571.                     ‘end_time’: session.get(‘end_time’),
  1572.                     ‘stream_url’: session.get(‘stream_url’)
  1573.                 })
  1574.             else:
  1575.                 return jsonify({
  1576.                     ‘success’: False,
  1577.                     ‘message’: ‘ 找不到录制会话 ’
  1578.                 })
  1579.     else:
  1580.         # 返回所有录制会话状态
  1581.         with recording_lock:
  1582.             sessions = {
  1583.                 sid: {
  1584.                     ‘status’: session[‘status’],
  1585.                     ‘output_path’: session.get(‘output_path’),
  1586.                     ‘start_time’: session.get(‘start_time’),
  1587.                     ‘end_time’: session.get(‘end_time’),
  1588.                     ‘stream_url’: session.get(‘stream_url’)
  1589.                 }
  1590.                 for sid, session in recording_sessions.items()
  1591.             }
  1592.         return jsonify({
  1593.             ‘success’: True,
  1594.             ‘sessions’: sessions
  1595.         })
  1596. @app.route(‘/api/multi-poll/add’, methods=[‘POST’])
  1597. @handle_exceptions
  1598. def add_polling_room():
  1599.     “”” 添加直播间到轮询列表 ”””
  1600.     data = request.get_json()
  1601.     room_url = data.get(‘room_url’)
  1602.     room_id = data.get(‘room_id’)
  1603.     check_interval = data.get(‘check_interval’, 60)  # 默认 60 秒检查一次
  1604.     auto_record = data.get(‘auto_record’, False)  # 是否自动录制
  1605.     if not room_url:
  1606.         return jsonify({
  1607.             ‘success’: False,
  1608.             ‘message’: ‘ 缺少直播间地址 ’
  1609.         })
  1610.     # 如果没有提供 room_id,尝试从 URL 解析
  1611.     if not room_id:
  1612.         # 处理不同格式的输入
  1613.         processed_url = room_url.strip()
  1614.         logger.info(f” 尝试解析 URL: {processed_url}”)
  1615.         # 1. 检查是否是纯数字(主播 ID)
  1616.         if re.match(r’^\d+$’, processed_url):
  1617.             logger.info(f” 检测到主播 ID 格式: {processed_url}”)
  1618.             room_id = processed_url
  1619.         # 2. 检查是否是完整的抖音直播 URL
  1620.         elif “douyin.com” in processed_url:
  1621.             logger.info(f” 检测到抖音 URL 格式: {processed_url}”)
  1622.             # 尝试多种 URL 格式的解析
  1623.             # 格式 1: https://live.douyin.com/123456
  1624.             room_id_match = re.search(r’live\.douyin\.com/([^/?&#]+)’, processed_url)
  1625.             if room_id_match:
  1626.                 room_id = room_id_match.group(1)
  1627.                 logger.info(f” 从 live.douyin.com URL 提取房间 ID: {room_id}”)
  1628.             else:
  1629.                 # 格式 2: https://www.douyin.com/user/MS4wLjABAAAA…
  1630.                 user_id_match = re.search(r’/user/([^/?&#]+)’, processed_url)
  1631.                 if user_id_match:
  1632.                     room_id = user_id_match.group(1)
  1633.                     logger.info(f” 从用户主页 URL 提取用户 ID: {room_id}”)
  1634.                 else:
  1635.                     # 格式 3: 尝试从 URL 路径中提取数字部分
  1636.                     url_parts = processed_url.split(‘/’)
  1637.                     for part in reversed(url_parts):
  1638.                         if part and part != ” and not part.startswith(‘?’):
  1639.                             # 移除可能的参数
  1640.                             clean_part = part.split(‘?’)[0].split(‘#’)[0]
  1641.                             if clean_part:
  1642.                                 # 如果是纯数字,直接使用
  1643.                                 if re.match(r’^\d+$’, clean_part):
  1644.                                     room_id = clean_part
  1645.                                     logger.info(f” 从 URL 路径提取房间 ID: {room_id}”)
  1646.                                     break
  1647.                                 # 否则使用完整的部分
  1648.                                 else:
  1649.                                     room_id = clean_part
  1650.                                     logger.info(f” 从 URL 路径提取标识符: {room_id}”)
  1651.                                     break
  1652.                     if not room_id:
  1653.                         return jsonify({
  1654.                             ‘success’: False,
  1655.                             ‘message’: f’ 无法从 URL 解析房间 ID: {processed_url}’
  1656.                         })
  1657.         # 3. 其他格式(可能是短链接或其他标识符)
  1658.         else:
  1659.             logger.info(f” 未识别的 URL 格式,尝试直接使用: {processed_url}”)
  1660.             room_id = processed_url
  1661.     logger.info(f” 最终解析得到的房间 ID: {room_id}”)
  1662.     success = multi_poller.add_room(room_id, room_url, check_interval, auto_record)
  1663.     if success:
  1664.         return jsonify({
  1665.             ‘success’: True,
  1666.             ‘message’: f’ 已添加直播间 {room_id} 到轮询列表 ’,
  1667.             ‘room_id’: room_id
  1668.         })
  1669.     else:
  1670.         return jsonify({
  1671.             ‘success’: False,
  1672.             ‘message’: f’ 直播间 {room_id} 已在轮询列表中 ’
  1673.         })
  1674. @app.route(‘/api/multi-poll/remove’, methods=[‘POST’])
  1675. @handle_exceptions
  1676. def remove_polling_room():
  1677.     “”” 从轮询列表移除直播间 ”””
  1678.     data = request.get_json()
  1679.     room_id = data.get(‘room_id’)
  1680.     if not room_id:
  1681.         return jsonify({
  1682.             ‘success’: False,
  1683.             ‘message’: ‘ 缺少房间 ID’
  1684.         })
  1685.     success = multi_poller.remove_room(room_id)
  1686.     if success:
  1687.         return jsonify({
  1688.             ‘success’: True,
  1689.             ‘message’: f’ 已移除直播间 {room_id}’
  1690.         })
  1691.     else:
  1692.         return jsonify({
  1693.             ‘success’: False,
  1694.             ‘message’: f’ 直播间 {room_id} 不在轮询列表中 ’
  1695.         })
  1696. @app.route(‘/api/multi-poll/status’, methods=[‘GET’])
  1697. @handle_exceptions
  1698. def get_multi_polling_status():
  1699.     “”” 获取多直播间轮询状态 ”””
  1700.     status = multi_poller.get_status()
  1701.     return jsonify({
  1702.         ‘success’: True,
  1703.         ‘polling_rooms’: status,
  1704.         ‘total_rooms’: len(status)
  1705.     })
  1706. @app.route(‘/api/multi-poll/history’, methods=[‘GET’])
  1707. @handle_exceptions
  1708. def get_polling_history():
  1709.     “”” 获取轮询历史记录 ”””
  1710.     # 获取查询参数
  1711.     limit = request.args.get(‘limit’, 50, type=int)
  1712.     room_id = request.args.get(‘room_id’)
  1713.     action = request.args.get(‘action’)
  1714.     # 限制 limit 的范围
  1715.     limit = min(max(1, limit), 200)  # 限制在 1 -200 之间
  1716.     history = multi_poller.get_history(limit=limit, room_id=room_id, action=action)
  1717.     return jsonify({
  1718.         ‘success’: True,
  1719.         ‘history’: history,
  1720.         ‘total_records’: len(history),
  1721.         ‘filters’: {
  1722.             ‘limit’: limit,
  1723.             ‘room_id’: room_id,
  1724.             ‘action’: action
  1725.         }
  1726.     })
  1727. @app.route(‘/api/multi-poll/start-record’, methods=[‘POST’])
  1728. @handle_exceptions
  1729. def start_manual_recording():
  1730.     “”” 手动为指定直播间启动录制 ”””
  1731.     data = request.get_json()
  1732.     room_id = data.get(‘room_id’)
  1733.     if not room_id:
  1734.         return jsonify({
  1735.             ‘success’: False,
  1736.             ‘message’: ‘ 缺少房间 ID’
  1737.         })
  1738.     status = multi_poller.get_status()
  1739.     if room_id not in status:
  1740.         return jsonify({
  1741.             ‘success’: False,
  1742.             ‘message’: f’ 直播间 {room_id} 不在轮询列表中 ’
  1743.         })
  1744.     room_info = status[room_id]
  1745.     if room_info[‘status’] != ‘live’ or not room_info[‘stream_url’]:
  1746.         return jsonify({
  1747.             ‘success’: False,
  1748.             ‘message’: f’ 直播间 {room_id} 当前不在直播或无流地址 ’
  1749.         })
  1750.     if room_info[‘recording_session_id’]:
  1751.         return jsonify({
  1752.             ‘success’: False,
  1753.             ‘message’: f’ 直播间 {room_id} 已在录制中 ’
  1754.         })
  1755.     # 启动录制
  1756.     multi_poller._start_recording(room_id, room_info[‘stream_url’])
  1757.     # 简化版:不记录手动录制
  1758.     return jsonify({
  1759.         ‘success’: True,
  1760.         ‘message’: f’ 已为直播间 {room_id} 启动录制 ’
  1761.     })
  1762. @app.route(‘/api/multi-poll/stop-record’, methods=[‘POST’])
  1763. @handle_exceptions
  1764. def stop_manual_recording():
  1765.     “”” 手动停止指定直播间的录制 ”””
  1766.     data = request.get_json()
  1767.     room_id = data.get(‘room_id’)
  1768.     if not room_id:
  1769.         return jsonify({
  1770.             ‘success’: False,
  1771.             ‘message’: ‘ 缺少房间 ID’
  1772.         })
  1773.     status = multi_poller.get_status()
  1774.     if room_id not in status:
  1775.         return jsonify({
  1776.             ‘success’: False,
  1777.             ‘message’: f’ 直播间 {room_id} 不在轮询列表中 ’
  1778.         })
  1779.     room_info = status[room_id]
  1780.     if not room_info[‘recording_session_id’]:
  1781.         return jsonify({
  1782.             ‘success’: False,
  1783.             ‘message’: f’ 直播间 {room_id} 当前未在录制 ’
  1784.         })
  1785.     # 停止录制
  1786.     multi_poller._stop_recording(room_id)
  1787.     # 简化版:不记录手动停止录制
  1788.     return jsonify({
  1789.         ‘success’: True,
  1790.         ‘message’: f’ 已停止直播间 {room_id} 的录制 ’
  1791.     })
  1792. @app.route(‘/api/multi-poll/pause’, methods=[‘POST’])
  1793. @handle_exceptions
  1794. def pause_polling_room():
  1795.     “”” 暂停指定直播间的轮询 ”””
  1796.     data = request.get_json()
  1797.     room_id = data.get(‘room_id’)
  1798.     if not room_id:
  1799.         return jsonify({
  1800.             ‘success’: False,
  1801.             ‘message’: ‘ 缺少房间 ID’
  1802.         })
  1803.     success = multi_poller.pause_room(room_id)
  1804.     if success:
  1805.         return jsonify({
  1806.             ‘success’: True,
  1807.             ‘message’: f’ 已暂停直播间 {room_id} 的轮询 ’
  1808.         })
  1809.     else:
  1810.         return jsonify({
  1811.             ‘success’: False,
  1812.             ‘message’: f’ 直播间 {room_id} 不在轮询列表中或已暂停 ’
  1813.         })
  1814. @app.route(‘/api/multi-poll/resume’, methods=[‘POST’])
  1815. @handle_exceptions
  1816. def resume_polling_room():
  1817.     “”” 恢复指定直播间的轮询 ”””
  1818.     data = request.get_json()
  1819.     room_id = data.get(‘room_id’)
  1820.     if not room_id:
  1821.         return jsonify({
  1822.             ‘success’: False,
  1823.             ‘message’: ‘ 缺少房间 ID’
  1824.         })
  1825.     success = multi_poller.resume_room(room_id)
  1826.     if success:
  1827.         return jsonify({
  1828.             ‘success’: True,
  1829.             ‘message’: f’ 已恢复直播间 {room_id} 的轮询 ’
  1830.         })
  1831.     else:
  1832.         return jsonify({
  1833.             ‘success’: False,
  1834.             ‘message’: f’ 直播间 {room_id} 不在轮询列表中或未暂停 ’
  1835.         })
  1836. if __name__ == ‘__main__’:
  1837.     # 创建录制目录
  1838.     os.makedirs(‘recordings’, exist_ok=True)
  1839.     # 监听所有接口,允许外部访问
  1840.     app.run(host=’0.0.0.0′, port=5000, debug=True)

前端:

  1. <template>
  2.   <div class=”multi-room-manager”>
  3.     <div class=”header”>
  4.       <h3> 多直播间管理 </h3>
  5.       <div class=”header-actions”>
  6.         <button @click=”showHistory = !showHistory” class=”history-btn”>
  7.           {{showHistory ? ‘ 隐藏历史 ’ : ‘ 查看历史 ’}}
  8.         </button>
  9.         <button @click=”showAddDialog = true” class=”add-btn”> 添加直播间 </button>
  10.       </div>
  11.     </div>
  12.     <!– 播放器区域 –>
  13.     <div class=”players-section”>
  14.       <h3> 直播播放器 </h3>
  15.       <div class=”players-container”>
  16.         <div
  17.           v-for=”(player, index) in players”
  18.           :key=”index”
  19.           class=”player-wrapper”
  20.         >
  21.           <div class=”player-header”>
  22.             <span class=”player-title”>{{player.title}}</span>
  23.             <button @click=”closePlayer(index)” class=”close-player-btn”>×</button>
  24.           </div>
  25.           <div class=”player-controls”>
  26.             <button @click=”toggleMute(index)” class=”mute-btn”>
  27.               {{player.muted ? ‘&#128263; 静音 ’ : ‘&#128266; 取消静音 ’}}
  28.             </button>
  29.             <button @click=”play(index)” class=”play-btn”> 播放 </button>
  30.           </div>
  31.           <video :ref=”`videoPlayer${index}`” controls autoplay muted class=”inline-video-player”></video>
  32.           <div v-if=”player.error” class=”player-error”>{{player.error}}</div>
  33.         </div>
  34.         <div v-if=”players.length === 0″ class=”no-players”>
  35.           暂无播放器,请点击直播间中的 ” 播放 ” 按钮添加播放器
  36.         </div>
  37.       </div>
  38.     </div>
  39.     <!– 批量操作栏 –>
  40.     <div v-if=”selectedRooms.length > 0″ class=”bulk-action-bar”>
  41.       <div class=”bulk-info”>
  42.         已选择 {{selectedRooms.length}} 个直播间
  43.       </div>
  44.       <div class=”bulk-actions”>
  45.         <button @click=”bulkStartRecording” class=”bulk-record-btn”> 批量录制 </button>
  46.         <button @click=”bulkStopRecording” class=”bulk-stop-btn”> 批量停止录制 </button>
  47.         <button @click=”bulkPause” class=”bulk-pause-btn”> 批量暂停 </button>
  48.         <button @click=”bulkResume” class=”bulk-resume-btn”> 批量恢复 </button>
  49.         <button @click=”bulkRemove” class=”bulk-remove-btn”> 批量移除 </button>
  50.         <button @click=”clearSelection” class=”bulk-clear-btn”> 取消选择 </button>
  51.       </div>
  52.     </div>
  53.     <!– 添加直播间对话框 –>
  54.     <div v-if=”showAddDialog” class=”dialog-overlay”>
  55.       <div class=”dialog”>
  56.         <h4> 添加直播间 </h4>
  57.         <div class=”form-group”>
  58.           <label> 直播间地址:</label>
  59.           <input
  60.             v-model=”newRoom.url”
  61.             placeholder=” 输入房间号或直播链接(如:123456 或 https://live.douyin.com/123456)”
  62.             class=”input-field”
  63.           />
  64.         </div>
  65.         <div class=”form-group”>
  66.           <label> 检查间隔(秒):</label>
  67.           <input
  68.             v-model.number=”newRoom.interval”
  69.             type=”number”
  70.             placeholder=”60″
  71.             min=”30″
  72.             max=”3600″
  73.             class=”input-field”
  74.           />
  75.         </div>
  76.         <div class=”form-group”>
  77.           <label>
  78.             <input
  79.               v-model=”newRoom.autoRecord”
  80.               type=”checkbox”
  81.             />
  82.             开播时自动录制
  83.           </label>
  84.         </div>
  85.         <div class=”dialog-actions”>
  86.           <button @click=”addRoom” class=”confirm-btn”> 添加 </button>
  87.           <button @click=”cancelAdd” class=”cancel-btn”> 取消 </button>
  88.         </div>
  89.       </div>
  90.     </div>
  91.     <!– 直播间列表 –>
  92.     <div class=”room-list”>
  93.       <div
  94.         v-for=”(room, roomId) in sortedPollingRooms”
  95.         :key=”roomId”
  96.         class=”room-item”
  97.         :class=”[getStatusClass(room.status), {‘selected’: selectedRooms.includes(roomId) }]”
  98.         @click.ctrl.exact=”toggleRoomSelection(roomId)”
  99.         @click.shift.exact=”selectRoomRange(roomId)”
  100.       >
  101.         <div class=”room-selection”>
  102.           <input
  103.             type=”checkbox”
  104.             :checked=”selectedRooms.includes(roomId)”
  105.             @click.stop=”toggleRoomSelection(roomId)”
  106.             class=”room-checkbox”
  107.           />
  108.         </div>
  109.         <div class=”room-info”>
  110.           <div class=”room-id”> 房间: {{roomId}}
  111.             <span v-if=”room.anchor_name && room.anchor_name !== `anchor_${roomId}`” class=”anchor-name”>
  112.               ({{room.anchor_name}})
  113.             </span>
  114.           </div>
  115.           <div class=”room-status”>
  116.             状态: {{getStatusText(room.status) }}
  117.             <span v-if=”room.status === ‘live’ && (room.online_count > 0 || room.viewer_count_text)” class=”popularity”>
  118.               人气:{{formatPopularity(room) }}
  119.             </span>
  120.             <span v-if=”room.last_check” class=”last-check”>
  121.               ({{formatTime(room.last_check) }})
  122.             </span>
  123.           </div>
  124.           <div class=”room-url”>{{room.room_url}}</div>
  125.           <div v-if=”room.stream_url” class=”stream-url”>
  126.             流地址: {{room.stream_url.substring(0, 50) }}…
  127.           </div>
  128.         </div>
  129.         <div class=”room-actions”>
  130.           <!– 播放按钮 –>
  131.           <button
  132.             v-if=”room.status === ‘live’ && room.stream_url”
  133.             @click.stop=”playStream(room.stream_url)”
  134.             class=”play-btn”
  135.           >
  136.             播放
  137.           </button>
  138.           <!– 录制控制 –>
  139.           <button
  140.             v-if=”room.status === ‘live’ && !room.recording_session_id”
  141.             @click.stop=”startRecording(roomId)”
  142.             class=”record-btn”
  143.           >
  144.             开始录制
  145.           </button>
  146.           <button
  147.             v-if=”room.recording_session_id”
  148.             @click.stop=”stopRecording(roomId)”
  149.             class=”stop-record-btn”
  150.           >
  151.             停止录制
  152.           </button>
  153.           <!– 暂停 / 恢复按钮 –>
  154.           <button
  155.             v-if=”room.status !== ‘paused'”
  156.             @click.stop=”pauseRoom(roomId)”
  157.             class=”pause-btn”
  158.           >
  159.             暂停
  160.           </button>
  161.           <button
  162.             v-else
  163.             @click.stop=”resumeRoom(roomId)”
  164.             class=”resume-btn”
  165.           >
  166.             恢复
  167.           </button>
  168.           <!– 删除直播间 –>
  169.           <button
  170.             @click.stop=”removeRoom(roomId)”
  171.             class=”remove-btn”
  172.           >
  173.             移除
  174.           </button>
  175.         </div>
  176.       </div>
  177.     </div>
  178.     <!– 统计信息 –>
  179.     <div class=”stats”>
  180.       <div class=”stat-item”>
  181.         <span class=”stat-label”> 总房间数:</span>
  182.         <span class=”stat-value”>{{totalRooms}}</span>
  183.       </div>
  184.       <div class=”stat-item”>
  185.         <span class=”stat-label”> 在线房间:</span>
  186.         <span class=”stat-value”>{{liveRooms}}</span>
  187.       </div>
  188.       <div class=”stat-item”>
  189.         <span class=”stat-label”> 录制中:</span>
  190.         <span class=”stat-value”>{{recordingRooms}}</span>
  191.       </div>
  192.       <div class=”stat-item”>
  193.         <span class=”stat-label”> 已暂停:</span>
  194.         <span class=”stat-value”>{{pausedRooms}}</span>
  195.       </div>
  196.     </div>
  197.     <!– 错误信息 –>
  198.     <div v-if=”error” class=”error-message”>
  199.       {{error}}
  200.     </div>
  201.     <!– 历史记录区域 –>
  202.     <div v-if=”showHistory” class=”history-section”>
  203.       <div class=”history-header”>
  204.         <h4> 轮询历史记录 </h4>
  205.         <div class=”history-filters”>
  206.           <button @click=”refreshHistory” class=”refresh-btn”> 刷新 </button>
  207.         </div>
  208.       </div>
  209.       <div class=”history-list”>
  210.         <div v-if=”historyLoading” class=”loading”> 加载中 …</div>
  211.         <div v-else-if=”historyRecords.length === 0″ class=”no-history”> 暂无历史记录 </div>
  212.         <div v-else>
  213.           <div
  214.             v-for=”record in historyRecords”
  215.             :key=”record.id”
  216.             class=”history-item”
  217.           >
  218.             <div class=”history-info”>
  219.               <div class=”history-main”>
  220.                 <span class=”anchor-name”>{{record.anchor_name}}</span>
  221.                 <span class=”room-url”>{{record.room_url}}</span>
  222.               </div>
  223.               <div class=”history-time”>{{record.date}} {{record.time}}</div>
  224.             </div>
  225.           </div>
  226.           <!– 加载更多按钮 –>
  227.           <div v-if=”historyRecords.length >= 50″ class=”load-more”>
  228.             <button @click=”loadMoreHistory” class=”load-more-btn”> 加载更多 </button>
  229.           </div>
  230.         </div>
  231.       </div>
  232.     </div>
  233.   </div>
  234. </template>
  235. <script>
  236. import flvjs from ‘flv.js’;
  237. export default {
  238.   name: ‘MultiRoomManager’,
  239.   props: {
  240.   },
  241.   data() {
  242.     return {
  243.       pollingRooms: {},
  244.       showAddDialog: false,
  245.       showHistory: false,
  246.       newRoom: {
  247.         url: ”,
  248.         interval: 60,
  249.         autoRecord: false
  250.       },
  251.       error: ”,
  252.       updateInterval: null,
  253.       historyRecords: [],
  254.       historyLoading: false,
  255.       // 播放器列表,支持多个播放器
  256.       players: [],
  257.       selectedRooms: [],
  258.       lastSelectedRoom: null,
  259.       playerError: ”
  260.     };
  261.   },
  262.   computed: {
  263.     totalRooms() {
  264.       return Object.keys(this.pollingRooms).length;
  265.     },
  266.     liveRooms() {
  267.       return Object.values(this.pollingRooms).filter(room => room.status === ‘live’).length;
  268.     },
  269.     recordingRooms() {
  270.       return Object.values(this.pollingRooms).filter(room => room.recording_session_id).length;
  271.     },
  272.     pausedRooms() {
  273.       return Object.values(this.pollingRooms).filter(room => room.status === ‘paused’).length;
  274.     },
  275.     // 新增:排序后的直播间列表
  276.     sortedPollingRooms() {
  277.       // 将对象转换为数组并排序
  278.       const roomsArray = Object.entries(this.pollingRooms);
  279.       // 排序规则:
  280.       // 1. 录制中的直播间在最上面
  281.       // 2. 在线但未录制的直播间
  282.       // 3. 暂停和直播结束的直播间在最下面
  283.       roomsArray.sort((a, b) => {
  284.         const [roomIdA, roomA] = a;
  285.         const [roomIdB, roomB] = b;
  286.         // 录制中的直播间优先级最高
  287.         const isRecordingA = roomA.recording_session_id ? 1 : 0;
  288.         const isRecordingB = roomB.recording_session_id ? 1 : 0;
  289.         if (isRecordingA !== isRecordingB) {
  290.           return isRecordingB – isRecordingA; // 录制中的在前面
  291.         }
  292.         // 在线状态的直播间优先级次之
  293.         const isLiveA = roomA.status === ‘live’ ? 1 : 0;
  294.         const isLiveB = roomB.status === ‘live’ ? 1 : 0;
  295.         if (isLiveA !== isLiveB) {
  296.           return isLiveB – isLiveA; // 在线的在前面
  297.         }
  298.         // 暂停和直播结束的直播间优先级最低
  299.         const isPausedOrEndedA = (roomA.status === ‘paused’ || roomA.status === ‘live_no_stream’) ? 1 : 0;
  300.         const isPausedOrEndedB = (roomB.status === ‘paused’ || roomB.status === ‘live_no_stream’) ? 1 : 0;
  301.         if (isPausedOrEndedA !== isPausedOrEndedB) {
  302.           return isPausedOrEndedA – isPausedOrEndedB; // 暂停和结束的在后面
  303.         }
  304.         // 如果优先级相同,按房间 ID 排序
  305.         return roomIdA.localeCompare(roomIdB);
  306.       });
  307.       // 转换回对象格式
  308.       const sortedRooms = {};
  309.       roomsArray.forEach(([roomId, room]) => {
  310.         sortedRooms[roomId] = room;
  311.       });
  312.       return sortedRooms;
  313.     }
  314.   },
  315.   mounted() {
  316.     this.loadStatus();
  317.     this.loadHistory(); // 加载历史记录
  318.     // 每 5 秒更新一次状态
  319.     this.updateInterval = setInterval(this.loadStatus, 5000);
  320.   },
  321.   beforeDestroy() {
  322.     if (this.updateInterval) {
  323.       clearInterval(this.updateInterval);
  324.     }
  325.     // 销毁所有播放器
  326.     this.players.forEach(playerObj => {
  327.       if (playerObj.player) {
  328.         playerObj.player.destroy();
  329.       }
  330.     });
  331.   },
  332.   methods: {
  333.     async loadStatus() {
  334.       try {
  335.         const response = await fetch(‘http://127.0.0.1:5000/api/multi-poll/status’);
  336.         if (!response.ok) {
  337.           throw new Error(`HTTP error! status: ${response.status}`);
  338.         }
  339.         const data = await response.json();
  340.         if (data.success) {
  341.           this.pollingRooms = data.polling_rooms;
  342.           this.error = ”;
  343.         } else {
  344.           this.error = data.message || ‘ 获取状态失败 ’;
  345.         }
  346.       } catch (error) {
  347.         console.error(‘ 获取状态失败:’, error);
  348.         this.error = ‘ 连接服务器失败 ’;
  349.       }
  350.     },
  351.     async addRoom() {
  352.       if (!this.newRoom.url.trim()) {
  353.         this.error = ‘ 请输入直播间地址 ’;
  354.         return;
  355.       }
  356.       try {
  357.         const requestData = {
  358.           room_url: this.newRoom.url.trim(),
  359.           check_interval: this.newRoom.interval,
  360.           auto_record: this.newRoom.autoRecord
  361.         };
  362.         console.log(‘ 发送添加直播间请求:’, requestData);
  363.         const response = await fetch(‘http://127.0.0.1:5000/api/multi-poll/add’, {
  364.           method: ‘POST’,
  365.           headers: {‘Content-Type’: ‘application/json’},
  366.           body: JSON.stringify(requestData)
  367.         });
  368.         if (!response.ok) {
  369.           throw new Error(`HTTP error! status: ${response.status}`);
  370.         }
  371.         const data = await response.json();
  372.         console.log(‘ 后端响应:’, data);
  373.         if (data.success) {
  374.           this.showAddDialog = false;
  375.           this.resetNewRoom();
  376.           this.loadStatus(); // 刷新状态
  377.           this.error = ”;
  378.           console.log(‘ 直播间添加成功:’, data.room_id);
  379.         } else {
  380.           this.error = data.message || ‘ 添加失败 ’;
  381.           console.error(‘ 后端返回错误:’, data.message);
  382.         }
  383.       } catch (error) {
  384.         console.error(‘ 添加直播间失败:’, error);
  385.         this.error = ‘ 添加直播间失败: ‘ + error.message;
  386.       }
  387.     },
  388.     async removeRoom(roomId) {
  389.       if (!confirm(` 确定要移除直播间 ${roomId} 吗?`)) {
  390.         return;
  391.       }
  392.       try {
  393.         const response = await fetch(‘http://127.0.0.1:5000/api/multi-poll/remove’, {
  394.           method: ‘POST’,
  395.           headers: {‘Content-Type’: ‘application/json’},
  396.           body: JSON.stringify({room_id: roomId})
  397.         });
  398.         if (!response.ok) {
  399.           throw new Error(`HTTP error! status: ${response.status}`);
  400.         }
  401.         const data = await response.json();
  402.         if (data.success) {
  403.           // 从选中列表中移除
  404.           const index = this.selectedRooms.indexOf(roomId);
  405.           if (index > -1) {
  406.             this.selectedRooms.splice(index, 1);
  407.           }
  408.           this.loadStatus(); // 刷新状态
  409.           this.error = ”;
  410.         } else {
  411.           this.error = data.message || ‘ 移除失败 ’;
  412.         }
  413.       } catch (error) {
  414.         console.error(‘ 移除直播间失败:’, error);
  415.         this.error = ‘ 移除直播间失败: ‘ + error.message;
  416.       }
  417.     },
  418.     async startRecording(roomId) {
  419.       try {
  420.         const response = await fetch(‘http://127.0.0.1:5000/api/multi-poll/start-record’, {
  421.           method: ‘POST’,
  422.           headers: {‘Content-Type’: ‘application/json’},
  423.           body: JSON.stringify({room_id: roomId})
  424.         });
  425.         if (!response.ok) {
  426.           throw new Error(`HTTP error! status: ${response.status}`);
  427.         }
  428.         const data = await response.json();
  429.         if (data.success) {
  430.           this.loadStatus(); // 刷新状态
  431.           this.error = ”;
  432.         } else {
  433.           this.error = data.message || ‘ 开始录制失败 ’;
  434.         }
  435.       } catch (error) {
  436.         console.error(‘ 开始录制失败:’, error);
  437.         this.error = ‘ 开始录制失败: ‘ + error.message;
  438.       }
  439.     },
  440.     async stopRecording(roomId) {
  441.       try {
  442.         const response = await fetch(‘http://127.0.0.1:5000/api/multi-poll/stop-record’, {
  443.           method: ‘POST’,
  444.           headers: {‘Content-Type’: ‘application/json’},
  445.           body: JSON.stringify({room_id: roomId})
  446.         });
  447.         if (!response.ok) {
  448.           throw new Error(`HTTP error! status: ${response.status}`);
  449.         }
  450.         const data = await response.json();
  451.         if (data.success) {
  452.           this.loadStatus(); // 刷新状态
  453.           this.error = ”;
  454.         } else {
  455.           this.error = data.message || ‘ 停止录制失败 ’;
  456.         }
  457.       } catch (error) {
  458.         console.error(‘ 停止录制失败:’, error);
  459.         this.error = ‘ 停止录制失败: ‘ + error.message;
  460.       }
  461.     },
  462.     // 新增:暂停直播间(停止轮询)
  463.     async pauseRoom(roomId) {
  464.       try {
  465.         const response = await fetch(‘http://127.0.0.1:5000/api/multi-poll/pause’, {
  466.           method: ‘POST’,
  467.           headers: {‘Content-Type’: ‘application/json’},
  468.           body: JSON.stringify({room_id: roomId})
  469.         });
  470.         if (!response.ok) {
  471.           throw new Error(`HTTP error! status: ${response.status}`);
  472.         }
  473.         const data = await response.json();
  474.         if (data.success) {
  475.           this.loadStatus(); // 刷新状态
  476.           this.error = ”;
  477.         } else {
  478.           this.error = data.message || ‘ 暂停失败 ’;
  479.         }
  480.       } catch (error) {
  481.         console.error(‘ 暂停直播间失败:’, error);
  482.         this.error = ‘ 暂停直播间失败: ‘ + error.message;
  483.       }
  484.     },
  485.     // 新增:恢复直播间(恢复轮询)
  486.     async resumeRoom(roomId) {
  487.       try {
  488.         const response = await fetch(‘http://127.0.0.1:5000/api/multi-poll/resume’, {
  489.           method: ‘POST’,
  490.           headers: {‘Content-Type’: ‘application/json’},
  491.           body: JSON.stringify({room_id: roomId})
  492.         });
  493.         if (!response.ok) {
  494.           throw new Error(`HTTP error! status: ${response.status}`);
  495.         }
  496.         const data = await response.json();
  497.         if (data.success) {
  498.           this.loadStatus(); // 刷新状态
  499.           this.error = ”;
  500.         } else {
  501.           this.error = data.message || ‘ 恢复失败 ’;
  502.         }
  503.       } catch (error) {
  504.         console.error(‘ 恢复直播间失败:’, error);
  505.         this.error = ‘ 恢复直播间失败: ‘ + error.message;
  506.       }
  507.     },
  508.     cancelAdd() {
  509.       this.showAddDialog = false;
  510.       this.resetNewRoom();
  511.     },
  512.     resetNewRoom() {
  513.       this.newRoom = {
  514.         url: ”,
  515.         interval: 60,
  516.         autoRecord: false
  517.       };
  518.     },
  519.     getStatusClass(status) {
  520.       return {
  521.         ‘status-live’: status === ‘live’,
  522.         ‘status-offline’: status === ‘offline’ || status === ‘live_no_stream’,
  523.         ‘status-checking’: status === ‘checking’,
  524.         ‘status-error’: status === ‘error’,
  525.         ‘status-waiting’: status === ‘waiting’,
  526.         ‘status-paused’: status === ‘paused’
  527.       };
  528.     },
  529.     getStatusText(status) {
  530.       const statusMap = {
  531.         ‘waiting’: ‘ 等待中 ’,
  532.         ‘checking’: ‘ 检查中 ’,
  533.         ‘live’: ‘ 在线 ’,
  534.         ‘offline’: ‘ 离线 ’,
  535.         ‘error’: ‘ 错误 ’,
  536.         ‘live_no_stream’: ‘ 直播结束 ’,
  537.         ‘paused’: ‘ 已暂停 ’
  538.       };
  539.       return statusMap[status] || status;
  540.     },
  541.     formatTime(timeStr) {
  542.       if (!timeStr) return ”;
  543.       const date = new Date(timeStr);
  544.       return date.toLocaleTimeString();
  545.     },
  546.     formatPopularity(room) {
  547.       // 优先使用原始文本(如 ”32 人在线 ”)
  548.       if (room.viewer_count_text && room.viewer_count_text.trim()) {
  549.         // 如果原始文本包含太多信息,尝试提取数字
  550.         if (room.viewer_count_text.length > 20) {
  551.           // 提取数字部分
  552.           const match = room.viewer_count_text.match(/(在线观众[\s·]*([\d,]+)| 观众[\s·]*([\d,]+)|([\d,]+)\s* 人在线)/);
  553.           if (match) {
  554.             const count = (match[2] || match[3] || match[4] || ‘0’).replace(‘,’, ”);
  555.             return `${count}人 `;
  556.           }
  557.         } else {
  558.           return room.viewer_count_text;
  559.         }
  560.       }
  561.       // 否则格式化数字
  562.       const count = room.online_count || 0;
  563.       if (count >= 10000) {
  564.         const wan = (count / 10000).toFixed(1);
  565.         return `${wan}万人 `;
  566.       } else if (count > 0) {
  567.         return `${count}人 `;
  568.       }
  569.       return ‘0 人 ’;
  570.     },
  571.     async loadHistory() {
  572.       this.historyLoading = true;
  573.       try {
  574.         const response = await fetch(‘http://127.0.0.1:5000/api/multi-poll/history?limit=50’);
  575.         if (!response.ok) {
  576.           throw new Error(`HTTP error! status: ${response.status}`);
  577.         }
  578.         const data = await response.json();
  579.         if (data.success) {
  580.           this.historyRecords = data.history;
  581.         } else {
  582.           console.error(‘ 获取历史记录失败:’, data.message);
  583.         }
  584.       } catch (error) {
  585.         console.error(‘ 加载历史记录失败:’, error);
  586.       } finally {
  587.         this.historyLoading = false;
  588.       }
  589.     },
  590.     async loadMoreHistory() {
  591.       // 加载更多历史记录(简单实现,可以扩展为真正的分页)
  592.       this.loadHistory();
  593.     },
  594.     refreshHistory() {
  595.       this.loadHistory();
  596.     },
  597.     // 新增:播放直播流
  598.     playStream(streamUrl) {
  599.       // 查找对应的直播间信息
  600.       let roomInfo = null;
  601.       let roomTitle = ‘ 未知直播间 ’;
  602.       // 遍历所有直播间查找匹配的流地址
  603.       for (const [roomId, room] of Object.entries(this.pollingRooms)) {
  604.         if (room.stream_url === streamUrl && room.status === ‘live’) {
  605.           roomInfo = room;
  606.           // 使用主播名作为标题,如果没有则使用房间 ID
  607.           roomTitle = (room.anchor_name && room.anchor_name !== `anchor_${roomId}`) ? room.anchor_name : ` 房间 ${roomId}`;
  608.           break;
  609.         }
  610.       }
  611.       // 添加新的播放器到播放器列表
  612.       const playerIndex = this.players.length;
  613.       this.players.push({
  614.         url: streamUrl,
  615.         player: null,
  616.         error: ”,
  617.         muted: true,  // 默认静音
  618.         title: roomTitle  // 添加直播间标题
  619.       });
  620.       this.$nextTick(() => {
  621.         this.initPlayer(playerIndex);
  622.       });
  623.     },
  624.     // 初始化 FLV 播放器
  625.     initPlayer(playerIndex) {
  626.       // 销毁已存在的播放器
  627.       if (this.players[playerIndex].player) {
  628.         this.players[playerIndex].player.destroy();
  629.         this.players[playerIndex].player = null;
  630.       }
  631.       this.players[playerIndex].error = ”;
  632.       try {
  633.         if (flvjs.isSupported()) {
  634.           const videoElement = this.$refs[`videoPlayer${playerIndex}`][0];
  635.           this.players[playerIndex].player = flvjs.createPlayer({
  636.             type: ‘flv’,
  637.             url: this.players[playerIndex].url
  638.           });
  639.           this.players[playerIndex].player.attachMediaElement(videoElement);
  640.           this.players[playerIndex].player.load();
  641.           // 设置默认静音状态
  642.           videoElement.muted = this.players[playerIndex].muted;
  643.           this.players[playerIndex].player.play().catch(error => {
  644.             console.error(‘ 播放失败:’, error);
  645.             this.players[playerIndex].error = ‘ 播放失败: ‘ + error.message;
  646.           });
  647.         } else {
  648.           this.players[playerIndex].error = ‘ 当前浏览器不支持 FLV 播放 ’;
  649.           console.error(‘FLV.js is not supported’);
  650.         }
  651.       } catch (error) {
  652.         console.error(‘ 初始化播放器失败:’, error);
  653.         this.players[playerIndex].error = ‘ 初始化播放器失败: ‘ + error.message;
  654.       }
  655.     },
  656.     // 新增:关闭播放器
  657.     closePlayer(playerIndex) {
  658.       // 销毁指定的播放器
  659.       if (this.players[playerIndex].player) {
  660.         this.players[playerIndex].player.destroy();
  661.         this.players[playerIndex].player = null;
  662.       }
  663.       // 从播放器列表中移除
  664.       this.players.splice(playerIndex, 1);
  665.     },
  666.     // 新增:切换静音状态
  667.     toggleMute(playerIndex) {
  668.       const playerObj = this.players[playerIndex];
  669.       if (playerObj.player) {
  670.         const videoElement = this.$refs[`videoPlayer${playerIndex}`][0];
  671.         playerObj.muted = !playerObj.muted;
  672.         videoElement.muted = playerObj.muted;
  673.       }
  674.     },
  675.     // 新增:播放方法
  676.     play(playerIndex) {
  677.       const playerObj = this.players[playerIndex];
  678.       if (playerObj.player) {
  679.         // 取消静音并播放
  680.         playerObj.muted = false;
  681.         const videoElement = this.$refs[`videoPlayer${playerIndex}`][0];
  682.         videoElement.muted = false;
  683.         playerObj.player.play().catch(error => {
  684.           console.error(‘ 播放失败:’, error);
  685.           playerObj.error = ‘ 播放失败: ‘ + error.message;
  686.         });
  687.       }
  688.     },
  689.     // 新增:切换直播间选择
  690.     toggleRoomSelection(roomId) {
  691.       const index = this.selectedRooms.indexOf(roomId);
  692.       if (index > -1) {
  693.         // 如果已选中,则取消选中
  694.         this.selectedRooms.splice(index, 1);
  695.       } else {
  696.         // 如果未选中,则选中
  697.         this.selectedRooms.push(roomId);
  698.       }
  699.       this.lastSelectedRoom = roomId;
  700.     },
  701.     // 新增:选择范围内的直播间(Shift 键功能)
  702.     selectRoomRange(roomId) {
  703.       if (!this.lastSelectedRoom) {
  704.         this.toggleRoomSelection(roomId);
  705.         return;
  706.       }
  707.       const roomIds = Object.keys(this.pollingRooms);
  708.       const lastIndex = roomIds.indexOf(this.lastSelectedRoom);
  709.       const currentIndex = roomIds.indexOf(roomId);
  710.       if (lastIndex === -1 || currentIndex === -1) {
  711.         this.toggleRoomSelection(roomId);
  712.         return;
  713.       }
  714.       // 确定范围
  715.       const start = Math.min(lastIndex, currentIndex);
  716.       const end = Math.max(lastIndex, currentIndex);
  717.       // 选中范围内的所有直播间
  718.       const newSelection = roomIds.slice(start, end + 1);
  719.       // 合并选中项(避免重复)
  720.       const uniqueSelection = […new Set([…this.selectedRooms, …newSelection])];
  721.       this.selectedRooms = uniqueSelection;
  722.       this.lastSelectedRoom = roomId;
  723.     },
  724.     // 新增:清除选择
  725.     clearSelection() {
  726.       this.selectedRooms = [];
  727.       this.lastSelectedRoom = null;
  728.     },
  729.     // 新增:批量开始录制
  730.     async bulkStartRecording() {
  731.       if (this.selectedRooms.length === 0) {
  732.         this.error = ‘ 请先选择直播间 ’;
  733.         return;
  734.       }
  735.       let successCount = 0;
  736.       let failCount = 0;
  737.       for (const roomId of this.selectedRooms) {
  738.         try {
  739.           // 检查直播间是否在线且未在录制
  740.           const room = this.pollingRooms[roomId];
  741.           if (room.status === ‘live’ && !room.recording_session_id) {
  742.             await this.startRecording(roomId);
  743.             successCount++;
  744.           }
  745.         } catch (error) {
  746.           console.error(` 批量开始录制失败 (房间 ${roomId}):`, error);
  747.           failCount++;
  748.         }
  749.       }
  750.       this.error = ` 批量开始录制完成: 成功 ${successCount} 个, 失败 ${failCount} 个 `;
  751.       // 重新加载状态以更新界面
  752.       await this.loadStatus();
  753.     },
  754.     // 新增:批量停止录制
  755.     async bulkStopRecording() {
  756.       if (this.selectedRooms.length === 0) {
  757.         this.error = ‘ 请先选择直播间 ’;
  758.         return;
  759.       }
  760.       let successCount = 0;
  761.       let failCount = 0;
  762.       for (const roomId of this.selectedRooms) {
  763.         try {
  764.           // 检查直播间是否正在录制
  765.           const room = this.pollingRooms[roomId];
  766.           if (room.recording_session_id) {
  767.             await this.stopRecording(roomId);
  768.             successCount++;
  769.           }
  770.         } catch (error) {
  771.           console.error(` 批量停止录制失败 (房间 ${roomId}):`, error);
  772.           failCount++;
  773.         }
  774.       }
  775.       this.error = ` 批量停止录制完成: 成功 ${successCount} 个, 失败 ${failCount} 个 `;
  776.       // 重新加载状态以更新界面
  777.       await this.loadStatus();
  778.     },
  779.     // 新增:批量暂停(停止轮询)
  780.     async bulkPause() {
  781.       if (this.selectedRooms.length === 0) {
  782.         this.error = ‘ 请先选择直播间 ’;
  783.         return;
  784.       }
  785.       let successCount = 0;
  786.       let failCount = 0;
  787.       for (const roomId of this.selectedRooms) {
  788.         try {
  789.           // 检查直播间是否未暂停
  790.           const room = this.pollingRooms[roomId];
  791.           if (room.status !== ‘paused’) {
  792.             await this.pauseRoom(roomId);
  793.             successCount++;
  794.           }
  795.         } catch (error) {
  796.           console.error(` 批量暂停失败 (房间 ${roomId}):`, error);
  797.           failCount++;
  798.         }
  799.       }
  800.       this.error = ` 批量暂停完成: 成功 ${successCount} 个, 失败 ${failCount} 个 `;
  801.       // 重新加载状态以更新界面
  802.       await this.loadStatus();
  803.     },
  804.     // 新增:批量恢复(恢复轮询)
  805.     async bulkResume() {
  806.       if (this.selectedRooms.length === 0) {
  807.         this.error = ‘ 请先选择直播间 ’;
  808.         return;
  809.       }
  810.       let successCount = 0;
  811.       let failCount = 0;
  812.       for (const roomId of this.selectedRooms) {
  813.         try {
  814.           // 检查直播间是否已暂停
  815.           const room = this.pollingRooms[roomId];
  816.           if (room.status === ‘paused’) {
  817.             await this.resumeRoom(roomId);
  818.             successCount++;
  819.           }
  820.         } catch (error) {
  821.           console.error(` 批量恢复失败 (房间 ${roomId}):`, error);
  822.           failCount++;
  823.         }
  824.       }
  825.       this.error = ` 批量恢复完成: 成功 ${successCount} 个, 失败 ${failCount} 个 `;
  826.       // 重新加载状态以更新界面
  827.       await this.loadStatus();
  828.     },
  829.     // 新增:批量移除
  830.     async bulkRemove() {
  831.       if (this.selectedRooms.length === 0) {
  832.         this.error = ‘ 请先选择直播间 ’;
  833.         return;
  834.       }
  835.       if (!confirm(` 确定要移除选中的 ${this.selectedRooms.length} 个直播间吗?`)) {
  836.         return;
  837.       }
  838.       let successCount = 0;
  839.       let failCount = 0;
  840.       // 创建选中房间的副本,因为在移除过程中会修改 selectedRooms 数组
  841.       const roomsToRemove = […this.selectedRooms];
  842.       for (const roomId of roomsToRemove) {
  843.         try {
  844.           await this.removeRoom(roomId);
  845.           successCount++;
  846.         } catch (error) {
  847.           console.error(` 批量移除失败 (房间 ${roomId}):`, error);
  848.           failCount++;
  849.         }
  850.       }
  851.       // 清空选中列表
  852.       this.selectedRooms = [];
  853.       this.lastSelectedRoom = null;
  854.       this.error = ` 批量移除完成: 成功 ${successCount} 个, 失败 ${failCount} 个 `;
  855.       // 重新加载状态以更新界面
  856.       await this.loadStatus();
  857.     }
  858.   }
  859. };
  860. </script>
  861. <style scoped>
  862. .multi-room-manager {
  863.   background-color: #1e2127;
  864.   border-radius: 8px;
  865.   padding: 20px;
  866.   color: white;
  867. }
  868. .header {
  869.   display: flex;
  870.   justify-content: space-between;
  871.   align-items: center;
  872.   margin-bottom: 20px;
  873.   border-bottom: 1px solid #61dafb;
  874.   padding-bottom: 10px;
  875. }
  876. .header-actions {
  877.   display: flex;
  878.   gap: 10px;
  879. }
  880. /* 新增:批量操作栏样式 */
  881. .bulk-action-bar {
  882.   display: flex;
  883.   justify-content: space-between;
  884.   align-items: center;
  885.   background-color: #2d3748;
  886.   border-radius: 6px;
  887.   padding: 10px 15px;
  888.   margin-bottom: 15px;
  889.   border: 1px solid #4a5568;
  890. }
  891. .bulk-info {
  892.   font-weight: bold;
  893.   color: #61dafb;
  894. }
  895. .bulk-actions {
  896.   display: flex;
  897.   gap: 10px;
  898.   flex-wrap: wrap;
  899. }
  900. .bulk-record-btn, .bulk-stop-btn, .bulk-pause-btn, .bulk-resume-btn, .bulk-remove-btn, .bulk-clear-btn {
  901.   padding: 6px 12px;
  902.   border: none;
  903.   border-radius: 4px;
  904.   cursor: pointer;
  905.   font-size: 12px;
  906.   min-width: 80px;
  907. }
  908. .bulk-record-btn {
  909.   background-color: #4caf50;
  910.   color: white;
  911. }
  912. .bulk-stop-btn {
  913.   background-color: #ff9800;
  914.   color: white;
  915. }
  916. .bulk-pause-btn {
  917.   background-color: #ff5722;
  918.   color: white;
  919. }
  920. .bulk-resume-btn {
  921.   background-color: #2196f3;
  922.   color: white;
  923. }
  924. .bulk-remove-btn {
  925.   background-color: #f44336;
  926.   color: white;
  927. }
  928. .bulk-clear-btn {
  929.   background-color: #6c757d;
  930.   color: white;
  931. }
  932. .history-btn {
  933.   background-color: #2196f3;
  934.   color: white;
  935.   border: none;
  936.   padding: 8px 16px;
  937.   border-radius: 4px;
  938.   cursor: pointer;
  939.   font-weight: bold;
  940. }
  941. .history-btn:hover {
  942.   background-color: #1976d2;
  943. }
  944. .parser-btn {
  945.   background-color: #ff9800;
  946.   color: white;
  947.   border: none;
  948.   padding: 8px 16px;
  949.   border-radius: 4px;
  950.   cursor: pointer;
  951.   font-weight: bold;
  952. }
  953. .parser-btn:hover {
  954.   background-color: #f57c00;
  955. }
  956. .add-btn {
  957.   background-color: #61dafb;
  958.   color: #282c34;
  959.   border: none;
  960.   padding: 8px 16px;
  961.   border-radius: 4px;
  962.   cursor: pointer;
  963.   font-weight: bold;
  964. }
  965. .add-btn:hover {
  966.   background-color: #4fa8c5;
  967. }
  968. .dialog-overlay {
  969.   position: fixed;
  970.   top: 0;
  971.   left: 0;
  972.   right: 0;
  973.   bottom: 0;
  974.   background-color: rgba(0, 0, 0, 0.5);
  975.   display: flex;
  976.   justify-content: center;
  977.   align-items: center;
  978.   z-index: 1000;
  979. }
  980. .dialog {
  981.   background-color: #282c34;
  982.   border-radius: 8px;
  983.   padding: 20px;
  984.   width: 400px;
  985.   max-width: 90vw;
  986. }
  987. .form-group {
  988.   margin-bottom: 15px;
  989. }
  990. .form-group label {
  991.   display: block;
  992.   margin-bottom: 5px;
  993.   color: #61dafb;
  994. }
  995. .input-field {
  996.   width: 100%;
  997.   padding: 8px;
  998.   border: 1px solid #61dafb;
  999.   border-radius: 4px;
  1000.   background-color: #1e2127;
  1001.   color: white;
  1002.   box-sizing: border-box;
  1003. }
  1004. .dialog-actions {
  1005.   display: flex;
  1006.   gap: 10px;
  1007.   margin-top: 20px;
  1008. }
  1009. .confirm-btn {
  1010.   background-color: #4caf50;
  1011.   color: white;
  1012.   border: none;
  1013.   padding: 8px 16px;
  1014.   border-radius: 4px;
  1015.   cursor: pointer;
  1016.   flex: 1;
  1017. }
  1018. .cancel-btn {
  1019.   background-color: #f44336;
  1020.   color: white;
  1021.   border: none;
  1022.   padding: 8px 16px;
  1023.   border-radius: 4px;
  1024.   cursor: pointer;
  1025.   flex: 1;
  1026. }
  1027. .room-list {
  1028.   max-height: 400px;
  1029.   overflow-y: auto;
  1030. }
  1031. .room-item {
  1032.   border: 1px solid #444;
  1033.   border-radius: 6px;
  1034.   padding: 15px;
  1035.   margin-bottom: 10px;
  1036.   display: flex;
  1037.   justify-content: space-between;
  1038.   align-items: flex-start;
  1039.   cursor: pointer;
  1040.   transition: background-color 0.2s;
  1041. }
  1042. .room-item:hover {
  1043.   background-color: rgba(97, 218, 251, 0.05);
  1044. }
  1045. .room-item.selected {
  1046.   border-color: #61dafb;
  1047.   background-color: rgba(97, 218, 251, 0.15);
  1048. }
  1049. .status-live {
  1050.   border-color: #4caf50;
  1051.   background-color: rgba(76, 175, 80, 0.1);
  1052. }
  1053. .status-offline {
  1054.   border-color: #666;
  1055.   background-color: rgba(102, 102, 102, 0.1);
  1056. }
  1057. .status-checking {
  1058.   border-color: #ff9800;
  1059.   background-color: rgba(255, 152, 0, 0.1);
  1060. }
  1061. .status-error {
  1062.   border-color: #f44336;
  1063.   background-color: rgba(244, 67, 54, 0.1);
  1064. }
  1065. .status-waiting {
  1066.   border-color: #2196f3;
  1067.   background-color: rgba(33, 150, 243, 0.1);
  1068. }
  1069. .status-paused {
  1070.   border-color: #ff5722;
  1071.   background-color: rgba(255, 87, 34, 0.1);
  1072. }
  1073. .room-selection {
  1074.   display: flex;
  1075.   align-items: center;
  1076.   margin-right: 10px;
  1077. }
  1078. .room-checkbox {
  1079.   width: 18px;
  1080.   height: 18px;
  1081.   cursor: pointer;
  1082. }
  1083. .room-info {
  1084.   flex: 1;
  1085.   text-align: left;
  1086. }
  1087. .room-id {
  1088.   font-weight: bold;
  1089.   color: #61dafb;
  1090.   margin-bottom: 5px;
  1091. }
  1092. .anchor-name {
  1093.   color: #4caf50;
  1094.   font-weight: normal;
  1095.   font-size: 14px;
  1096. }
  1097. .room-status {
  1098.   font-size: 14px;
  1099.   margin-bottom: 5px;
  1100. }
  1101. .last-check {
  1102.   color: #888;
  1103.   font-size: 12px;
  1104. }
  1105. .popularity {
  1106.   color: #ff6b6b;
  1107.   font-weight: bold;
  1108.   font-size: 13px;
  1109.   margin-left: 8px;
  1110.   padding: 2px 6px;
  1111.   background-color: rgba(255, 107, 107, 0.1);
  1112.   border-radius: 3px;
  1113. }
  1114. .room-url {
  1115.   font-size: 12px;
  1116.   color: #aaa;
  1117.   margin-bottom: 5px;
  1118.   word-break: break-all;
  1119. }
  1120. .stream-url {
  1121.   font-size: 11px;
  1122.   color: #888;
  1123.   font-family: monospace;
  1124. }
  1125. .room-actions {
  1126.   display: flex;
  1127.   flex-direction: column;
  1128.   gap: 8px;
  1129. }
  1130. .play-btn, .record-btn, .stop-record-btn, .pause-btn, .resume-btn, .remove-btn {
  1131.   padding: 6px 12px;
  1132.   border: none;
  1133.   border-radius: 4px;
  1134.   cursor: pointer;
  1135.   font-size: 12px;
  1136.   min-width: 80px;
  1137. }
  1138. .play-btn {
  1139.   background-color: #2196f3;
  1140.   color: white;
  1141. }
  1142. .record-btn {
  1143.   background-color: #4caf50;
  1144.   color: white;
  1145. }
  1146. .stop-record-btn {
  1147.   background-color: #ff9800;
  1148.   color: white;
  1149. }
  1150. .pause-btn {
  1151.   background-color: #ff5722;
  1152.   color: white;
  1153. }
  1154. .resume-btn {
  1155.   background-color: #2196f3;
  1156.   color: white;
  1157. }
  1158. .remove-btn {
  1159.   background-color: #f44336;
  1160.   color: white;
  1161. }
  1162. .stats {
  1163.   display: flex;
  1164.   justify-content: space-around;
  1165.   margin-top: 20px;
  1166.   padding-top: 15px;
  1167.   border-top: 1px solid #444;
  1168. }
  1169. .stat-item {
  1170.   text-align: center;
  1171. }
  1172. .stat-label {
  1173.   display: block;
  1174.   font-size: 12px;
  1175.   color: #aaa;
  1176.   margin-bottom: 5px;
  1177. }
  1178. .stat-value {
  1179.   font-size: 18px;
  1180.   font-weight: bold;
  1181.   color: #61dafb;
  1182. }
  1183. .error-message {
  1184.   background-color: #f44336;
  1185.   color: white;
  1186.   padding: 10px;
  1187.   border-radius: 4px;
  1188.   margin-top: 15px;
  1189.   text-align: center;
  1190. }
  1191. /* 历史记录样式 */
  1192. .history-section {
  1193.   margin-top: 20px;
  1194.   border-top: 2px solid #61dafb;
  1195.   padding-top: 20px;
  1196. }
  1197. .history-header {
  1198.   display: flex;
  1199.   justify-content: space-between;
  1200.   align-items: center;
  1201.   margin-bottom: 15px;
  1202. }
  1203. .history-header h4 {
  1204.   color: #61dafb;
  1205.   margin: 0;
  1206. }
  1207. .history-filters {
  1208.   display: flex;
  1209.   gap: 10px;
  1210.   align-items: center;
  1211. }
  1212. .refresh-btn {
  1213.   background-color: #4caf50;
  1214.   color: white;
  1215.   border: none;
  1216.   padding: 5px 10px;
  1217.   border-radius: 4px;
  1218.   cursor: pointer;
  1219.   font-size: 12px;
  1220. }
  1221. .refresh-btn:hover {
  1222.   background-color: #45a049;
  1223. }
  1224. .history-list {
  1225.   max-height: 400px;
  1226.   overflow-y: auto;
  1227.   border: 1px solid #444;
  1228.   border-radius: 4px;
  1229.   padding: 10px;
  1230. }
  1231. .loading, .no-history {
  1232.   text-align: center;
  1233.   color: #aaa;
  1234.   padding: 20px;
  1235. }
  1236. .history-item {
  1237.   padding: 10px;
  1238.   margin-bottom: 8px;
  1239.   border-radius: 4px;
  1240.   border-left: 4px solid #61dafb;
  1241.   background-color: rgba(97, 218, 251, 0.1);
  1242. }
  1243. .history-info {
  1244.   text-align: left;
  1245. }
  1246. .history-main {
  1247.   display: flex;
  1248.   align-items: center;
  1249.   gap: 8px;
  1250.   margin-bottom: 5px;
  1251. }
  1252. .anchor-name {
  1253.   font-weight: bold;
  1254.   color: #61dafb;
  1255. }
  1256. .room-url {
  1257.   color: #aaa;
  1258.   font-size: 12px;
  1259.   word-break: break-all;
  1260. }
  1261. .history-time {
  1262.   color: #888;
  1263.   font-size: 11px;
  1264. }
  1265. .load-more {
  1266.   text-align: center;
  1267.   margin-top: 15px;
  1268. }
  1269. .load-more-btn {
  1270.   background-color: #61dafb;
  1271.   color: #282c34;
  1272.   border: none;
  1273.   padding: 8px 16px;
  1274.   border-radius: 4px;
  1275.   cursor: pointer;
  1276.   font-weight: bold;
  1277. }
  1278. .load-more-btn:hover {
  1279.   background-color: #4fa8c5;
  1280. }
  1281. /* 新增:播放器模态框样式 */
  1282. .player-modal {
  1283.   position: fixed;
  1284.   top: 0;
  1285.   left: 0;
  1286.   width: 100%;
  1287.   height: 100%;
  1288.   background-color: rgba(0, 0, 0, 0.8);
  1289.   display: flex;
  1290.   justify-content: center;
  1291.   align-items: center;
  1292.   z-index: 2000;
  1293. }
  1294. .player-content {
  1295.   background-color: #282c34;
  1296.   border-radius: 8px;
  1297.   padding: 20px;
  1298.   width: 80%;
  1299.   max-width: 800px;
  1300.   max-height: 80vh;
  1301. }
  1302. .player-header {
  1303.   display: flex;
  1304.   justify-content: space-between;
  1305.   align-items: center;
  1306.   margin-bottom: 15px;
  1307. }
  1308. .player-header h3 {
  1309.   margin: 0;
  1310.   color: #61dafb;
  1311. }
  1312. .close-btn {
  1313.   background-color: #f44336;
  1314.   color: white;
  1315.   border: none;
  1316.   padding: 5px 10px;
  1317.   border-radius: 4px;
  1318.   cursor: pointer;
  1319. }
  1320. .modal-video-player {
  1321.   width: 100%;
  1322.   height: auto;
  1323.   max-height: 60vh;
  1324.   background-color: #000;
  1325.   border-radius: 4px;
  1326. }
  1327. .players-section {
  1328.   margin-top: 20px;
  1329.   border-top: 2px solid #61dafb;
  1330.   padding-top: 20px;
  1331. }
  1332. .players-section h3 {
  1333.   color: #61dafb;
  1334.   margin-bottom: 15px;
  1335. }
  1336. .players-container {
  1337.   display: flex;
  1338.   flex-wrap: wrap;
  1339.   gap: 20px;
  1340. }
  1341. .player-wrapper {
  1342.   flex: 1;
  1343.   min-width: 300px;
  1344.   background-color: #2d3748;
  1345.   border-radius: 8px;
  1346.   padding: 15px;
  1347.   box-sizing: border-box;
  1348. }
  1349. .player-header {
  1350.   display: flex;
  1351.   justify-content: space-between;
  1352.   align-items: center;
  1353.   margin-bottom: 10px;
  1354. }
  1355. .player-title {
  1356.   font-weight: bold;
  1357.   color: #61dafb;
  1358. }
  1359. .close-player-btn {
  1360.   background-color: #f44336;
  1361.   color: white;
  1362.   border: none;
  1363.   width: 24px;
  1364.   height: 24px;
  1365.   border-radius: 50%;
  1366.   cursor: pointer;
  1367.   font-size: 16px;
  1368.   display: flex;
  1369.   align-items: center;
  1370.   justify-content: center;
  1371. }
  1372. .player-controls {
  1373.   display: flex;
  1374.   gap: 10px;
  1375.   margin-bottom: 10px;
  1376. }
  1377. .mute-btn, .play-btn {
  1378.   padding: 5px 10px;
  1379.   border: none;
  1380.   border-radius: 4px;
  1381.   cursor: pointer;
  1382.   font-size: 12px;
  1383. }
  1384. .mute-btn {
  1385.   background-color: #ff9800;
  1386.   color: white;
  1387. }
  1388. .play-btn {
  1389.   background-color: #4caf50;
  1390.   color: white;
  1391. }
  1392. .inline-video-player {
  1393.   width: 100%;
  1394.   height: 200px;
  1395.   background-color: #000;
  1396.   border-radius: 4px;
  1397. }
  1398. .player-error {
  1399.   color: #f44336;
  1400.   text-align: center;
  1401.   padding: 10px;
  1402.   margin-top: 10px;
  1403.   border: 1px solid #f44336;
  1404.   border-radius: 4px;
  1405.   background-color: rgba(244, 67, 54, 0.1);
  1406. }
  1407. .no-players {
  1408.   color: #888;
  1409.   font-style: italic;
  1410.   text-align: center;
  1411.   padding: 20px;
  1412.   width: 100%;
  1413. }
  1414. </style>
抖音直播无人值守全天候轮询录制工具 2.0
正文完
 0
suyan
版权声明:本站原创文章,由 suyan 于2025-10-13发表,共计97699字。
转载说明:转载本网站任何内容,请按照转载方式正确书写本站原文地址。本站提供的一切软件、教程和内容信息仅限用于学习和研究目的;不得将上述内容用于商业或者非法用途,否则,一切后果请用户自负。本站信息来自网络,版权争议与本站无关。您必须在下载后的24个小时之内,从您的电脑或手机中彻底删除上述内容。如果您喜欢该程序,请支持正版,购买注册,得到更好的正版服务。如有侵权请邮件与我们联系处理。敬请谅解!
评论(没有评论)
验证码