前端代码仅展示部分。请在下方下载完整源码。 先看效果图: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)
主要文件结构 - MoonTV-main/
- ├── douyin-frontend/
- │ ├── src/
- │ │ ├── App.vue (主应用组件)
- │ │ ├── MultiRoomManager.vue (多直播间管理器)
- │ │ └── assets/ (静态资源)
- │ ├── public/
- │ └── package.json
- ├── douyin-backend/
- │ ├── app.py (主应用文件)
- │ ├── saved_rooms.json (保存的直播间配置)
- │ ├── rooms_history.json (轮询历史记录)
- │ └── recordings/ (录制文件目录)
- └── docs/
- └── 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 到项目目录下 后端服务 前端服务 - cd douyin-frontend
- npm install # 首次运行需要安装依赖
- npm run serve
项目特点 - 开箱即用,无需复杂配置
- 支持多直播间同时监控
- 自动录制功能
- 数据本地持久化存储
- 历史记录去重功能
- 支持手机端短链接解析
- 可获取直播间实时数据(如在线人数等)
使用场景 - 直播平台观众数据监控
- 网红经济数据分析系统
- 直播带货效果评估工具
- 多平台直播状态监控中心
后端: - from flask import Flask, request, jsonify
- from flask_cors import CORS
- import requests
- import re
- import time
- import os
- import subprocess
- import threading
- import json
- import logging
- from datetime import datetime
- from functools import wraps
- app = Flask(__name__)
- CORS(app, resources={r”/*”: {“origins”: [“http://127.0.0.1:8080”, “http://localhost:8080”]}}, supports_credentials=True)
- # 配置日志
- logging.basicConfig(level=logging.INFO, format=’%(asctime)s – %(levelname)s – %(message)s’)
- logger = logging.getLogger(__name__)
- # 全局变量
- recording_sessions = {}
- recording_lock = threading.Lock()
- # 新增:多直播间轮询管理
- polling_sessions = {}
- polling_lock = threading.Lock()
- # 异常处理装饰器
- def handle_exceptions(func):
- @wraps(func)
- def wrapper(*args, **kwargs):
- try:
- return func(*args, **kwargs)
- except Exception as e:
- logger.error(f” 函数 {func.__name__} 执行失败: {str(e)}”, exc_info=True)
- return jsonify({
- ‘success’: False,
- ‘message’: f’ 服务器内部错误: {str(e)}’
- }), 500
- return wrapper
- def get_real_stream_url(url, max_retries=3):
- “””
- 解析抖音直播链接,获取真实的直播流地址
- :param url: 抖音直播链接
- :param max_retries: 最大重试次数
- :return: 直播流地址或 None
- “””
- # 存储捕获到的直播流地址的变量,放在循环外部以便在所有尝试结束后仍能访问
- captured_stream_urls = []
- for attempt in range(max_retries):
- try:
- from playwright.sync_api import sync_playwright
- with sync_playwright() as p:
- # 启动浏览器(无头模式)
- browser = p.chromium.launch(headless=True)
- context = browser.new_context(
- 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″,
- viewport={“width”: 1920, “height”: 1080}
- )
- page = context.new_page()
- # 创建一个事件,用于在捕获到直播流地址时通知主线程
- stream_captured_event = threading.Event()
- # 处理 URL 格式
- if not url.startswith(“http”):
- url = f”https://live.douyin.com/{url}”
- logger.info(f” 转换为完整 URL: {url}”)
- # 访问直播页面
- logger.info(f”[尝试{attempt + 1}] 开始访问页面: {url}”)
- page.goto(url, timeout=30000, wait_until=”domcontentloaded”)
- # 定义在捕获到直播流地址时的处理函数
- def on_stream_captured(url):
- logger.info(f”[尝试{attempt + 1}] 成功捕获到直播流地址: {url}”)
- if url not in captured_stream_urls:
- captured_stream_urls.append(url)
- logger.info(f”[尝试{attempt + 1}] 已保存直播流地址,当前共 {len(captured_stream_urls)} 个 ”)
- # 立即设置事件,通知主线程已捕获到直播流地址
- stream_captured_event.set()
- logger.info(f”[尝试{attempt + 1}] 已通知主线程捕获到直播流地址 ”)
- # 添加网络请求监听函数
- def handle_response(response):
- try:
- response_url = response.url
- if (response_url.endswith(‘.m3u8’) or
- response_url.endswith(‘.flv’) or
- (‘.flv?’ in response_url) or
- (‘.m3u8?’ in response_url) or
- (‘douyincdn.com’ in response_url and (‘stream’ in response_url or ‘pull’ in response_url)) or
- (‘video’ in response.headers.get(‘content-type’, ”) and not response_url.endswith(‘.mp4’))):
- on_stream_captured(response_url)
- except Exception as e:
- logger.warning(f” 处理响应失败: {e}”)
- page.on(“response”, handle_response)
- # 直接等待网络请求,最多等待 10 秒
- max_wait_time = 10
- logger.info(f”[尝试{attempt + 1}] 开始等待直播流地址捕获 …”)
- # 等待事件或超时
- for elapsed_time in range(1, max_wait_time + 1):
- # 先检查是否已经捕获到直播流地址
- if captured_stream_urls:
- logger.info(f”[尝试{attempt + 1}] 检测到已捕获 {len(captured_stream_urls)} 个直播流地址 ”)
- context.close()
- return captured_stream_urls[0] # 返回第一个捕获到的地址
- # 等待事件通知
- if stream_captured_event.wait(1): # 等待 1 秒
- logger.info(f”[尝试{attempt + 1}] 在 {elapsed_time} 秒后收到直播流地址捕获通知 ”)
- context.close()
- return captured_stream_urls[0] # 返回第一个捕获到的地址
- # 每 2 秒输出一次等待日志
- if elapsed_time % 2 == 0:
- logger.info(f”[尝试 {attempt + 1}] 等待网络请求中 … ({elapsed_time}/{max_wait_time} 秒)”)
- # 等待结束后最后检查一次变量
- if captured_stream_urls: # 变量不为空
- logger.info(f”[尝试{attempt + 1}] 等待结束后发现 {len(captured_stream_urls)} 个直播流地址 ”)
- context.close()
- return captured_stream_urls[0]
- else:
- logger.warning(f”[尝试{attempt + 1}] 等待结束后仍未捕获到直播流地址 ”)
- # 保存页面内容用于调试
- try:
- with open(‘debug_page_content.html’, ‘w’, encoding=’utf-8′) as f:
- f.write(page.content())
- except Exception as e:
- logger.warning(f” 保存调试文件失败: {e}”)
- # 最后一次检查是否捕获到直播流地址
- if captured_stream_urls:
- logger.info(f”[尝试{attempt + 1}] 关闭浏览器前发现已捕获到直播流地址 ”)
- context.close()
- return captured_stream_urls[0]
- context.close()
- if attempt < max_retries – 1:
- logger.info(f” 第 {attempt + 1} 次尝试失败,准备第 {attempt + 2} 次尝试 …”)
- time.sleep(2) # 重试前等待
- except Exception as e:
- logger.error(f” 解析直播流地址失败 (尝试 {attempt + 1}): {str(e)}”)
- # 即使发生异常,也检查是否已经捕获到直播流地址
- if captured_stream_urls:
- logger.info(f”[尝试{attempt + 1}] 尽管发生异常,但已捕获到直播流地址 ”)
- return captured_stream_urls[0]
- if attempt < max_retries – 1:
- time.sleep(2)
- continue
- # 最后一次检查是否有捕获到的直播流地址
- if captured_stream_urls:
- logger.info(f” 虽然所有 {max_retries} 次尝试报告失败,但已捕获到 {len(captured_stream_urls)} 个直播流地址 ”)
- return captured_stream_urls[0]
- logger.error(f” 所有 {max_retries} 次尝试均失败,未能捕获到直播流地址 ”)
- return None
- def parse_viewer_count(text):
- “””
- 解析观看人数文本为数字
- 例: “32 人在线 ” -> 32, “1.2 万人在看 ” -> 12000, “5000 人在看 ” -> 5000
- “””
- try:
- # 移除常见的文字,保留数字和单位
- clean_text = re.sub(r'[人在看观气线众]’, ”, text)
- # 查找数字和单位
- match = re.search(r'(\d+(?:\.\d+)?)\s*([万 w])?’, clean_text, re.IGNORECASE)
- if match:
- number = float(match.group(1))
- unit = match.group(2)
- # 如果有 ” 万 ” 或 ”w” 单位,乘以 10000
- if unit and unit.lower() in [‘ 万 ’, ‘w’]:
- number *= 10000
- return int(number)
- except Exception as e:
- logger.debug(f” 解析观看人数失败: {e}”)
- return 0
- def get_live_room_info(url, max_retries=3):
- “””
- 获取直播间详细信息,包括在线人数
- :param url: 抖音直播链接
- :param max_retries: 最大重试次数
- :return: 包含在线人数等信息的字典
- “””
- room_info = {
- ‘online_count’: 0,
- ‘is_live’: False,
- ‘stream_url’: None,
- ‘room_title’: ”,
- ‘anchor_name’: ”,
- ‘room_id’: ”,
- ‘viewer_count_text’: ” # 显示的观看人数文本(如 ”1.2 万人在看 ”)
- }
- for attempt in range(max_retries):
- try:
- from playwright.sync_api import sync_playwright
- with sync_playwright() as p:
- browser = p.chromium.launch(headless=True)
- context = browser.new_context(
- 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″,
- viewport={“width”: 1920, “height”: 1080}
- )
- page = context.new_page()
- # 存储捕获的数据
- captured_data = {
- ‘stream_urls’: [],
- ‘api_responses’: []
- }
- # 处理 URL 格式
- if not url.startswith(“http”):
- url = f”https://live.douyin.com/{url}”
- logger.info(f”[尝试{attempt + 1}] 开始获取直播间信息: {url}”)
- # 监听网络请求,捕获 API 响应
- def handle_response(response):
- try:
- response_url = response.url
- # 捕获直播流地址
- if (response_url.endswith(‘.m3u8’) or
- response_url.endswith(‘.flv’) or
- (‘.flv?’ in response_url) or
- (‘.m3u8?’ in response_url) or
- (‘douyincdn.com’ in response_url and (‘stream’ in response_url or ‘pull’ in response_url))):
- captured_data[‘stream_urls’].append(response_url)
- logger.info(f” 捕获到直播流: {response_url}”)
- # 捕获包含直播间信息的 API 响应
- if (‘webcast/room/’ in response_url or
- ‘webcast/web/’ in response_url or
- ‘/api/live_data/’ in response_url or
- ‘room_id’ in response_url):
- try:
- if response.status == 200:
- response_json = response.json()
- captured_data[‘api_responses’].append({
- ‘url’: response_url,
- ‘data’: response_json
- })
- logger.info(f” 捕获到 API 响应: {response_url}”)
- except Exception as json_error:
- logger.debug(f”API 响应解析失败: {json_error}”)
- except Exception as e:
- logger.debug(f” 处理响应失败: {e}”)
- page.on(“response”, handle_response)
- # 访问直播页面
- page.goto(url, timeout=30000, wait_until=”domcontentloaded”)
- # 等待页面加载并捕获网络请求
- time.sleep(5)
- # 尝试从页面元素获取信息
- try:
- # 方法 1: 通过页面元素获取在线人数 – 更精确的选择器
- online_selectors = [
- ‘[data-e2e=”living-avatar-name”]’,
- ‘[class*=”viewer”][class*=”count”]’,
- ‘[class*=”online”][class*=”count”]’,
- ‘[class*=”watching”][class*=”count”]’,
- ‘span:has-text(“ 在线观众 ”)’,
- ‘span:has-text(“ 观众 ”)’,
- ‘div:has-text(“ 在线观众 ”)’,
- ‘.webcast-chatroom___content span’
- ]
- viewer_text = “”
- # 首先尝试找到 ” 在线观众 ” 相关的元素
- for selector in online_selectors:
- try:
- elements = page.query_selector_all(selector)
- for element in elements:
- text = element.inner_text().strip()
- # 更严格的匹配条件,只要包含 ” 在线观众 ” 或纯数字的
- if (‘ 在线观众 ’ in text or ‘ 观众 ’ in text) and any(c.isdigit() for c in text):
- # 提取 ” 在线观众 · 32″ 这样的格式
- import re
- match = re.search(r’ 在线观众[\s·]*([\d,]+)’, text)
- if match:
- viewer_text = f”{match.group(1)}人在线 ”
- logger.info(f” 找到在线观众数: {viewer_text}”)
- break
- # 或者提取 ” 观众 32″ 这样的格式
- match = re.search(r’ 观众[\s·]*([\d,]+)’, text)
- if match:
- viewer_text = f”{match.group(1)}人在线 ”
- logger.info(f” 找到观众数: {viewer_text}”)
- break
- if viewer_text:
- break
- except Exception as e:
- logger.debug(f” 选择器 {selector} 解析失败: {e}”)
- # 如果没找到,尝试从页面内容中提取 ” 在线观众 ” 信息
- if not viewer_text:
- page_content = page.content()
- # 使用正则表达式精确匹配 ” 在线观众 · 数字 ” 格式
- patterns = [
- r’ 在线观众[\s·]*([\d,]+)’,
- r’ 观众[\s·]*([\d,]+)’,
- r'(\d+)\s* 人在线 ’,
- r'(\d+)\s* 观看 ’
- ]
- for pattern in patterns:
- matches = re.findall(pattern, page_content)
- if matches:
- # 取第一个匹配的数字
- count_str = matches[0].replace(‘,’, ”) # 移除千分位逗号
- try:
- count = int(count_str)
- viewer_text = f”{count}人在线 ”
- logger.info(f” 通过正则表达式获取到观众数: {viewer_text}”)
- break
- except ValueError:
- continue
- # 解析人数文本为数字
- if viewer_text:
- room_info[‘viewer_count_text’] = viewer_text
- online_count = parse_viewer_count(viewer_text)
- room_info[‘online_count’] = online_count
- except Exception as e:
- logger.warning(f” 从页面元素获取在线人数失败: {e}”)
- # 方法 2: 从 API 响应中提取信息
- for api_resp in captured_data[‘api_responses’]:
- try:
- data = api_resp[‘data’]
- # 抖音 API 响应结构可能包含以下字段
- if ‘data’ in data:
- room_data = data[‘data’]
- # 在线人数
- if ‘user_count’ in room_data:
- room_info[‘online_count’] = max(room_info[‘online_count’], room_data[‘user_count’])
- elif ‘stats’ in room_data and ‘user_count’ in room_data[‘stats’]:
- room_info[‘online_count’] = max(room_info[‘online_count’], room_data[‘stats’][‘user_count’])
- elif ‘room_view_stats’ in room_data:
- room_info[‘online_count’] = max(room_info[‘online_count’], room_data[‘room_view_stats’].get(‘display_long’, 0))
- # 直播状态
- if ‘status’ in room_data:
- room_info[‘is_live’] = room_data[‘status’] == 2 # 2 通常表示正在直播
- # 房间标题
- if ‘title’ in room_data:
- room_info[‘room_title’] = room_data[‘title’]
- # 主播名称
- if ‘owner’ in room_data and ‘nickname’ in room_data[‘owner’]:
- room_info[‘anchor_name’] = room_data[‘owner’][‘nickname’]
- # 房间 ID
- if ‘id_str’ in room_data:
- room_info[‘room_id’] = room_data[‘id_str’]
- except Exception as e:
- logger.debug(f” 解析 API 响应失败: {e}”)
- # 设置直播流地址
- if captured_data[‘stream_urls’]:
- room_info[‘stream_url’] = captured_data[‘stream_urls’][0]
- room_info[‘is_live’] = True
- # 如果没有从 API 获取到在线人数,尝试页面内容检测
- if room_info[‘online_count’] == 0 and not room_info[‘viewer_count_text’]:
- try:
- page_content = page.content()
- # 使用更精确的正则表达式从页面内容中提取人数
- patterns = [
- r’ 在线观众[\s·]*([\d,]+)’, # “ 在线观众 · 32”
- r’ 观众[\s·]*([\d,]+)’, # “ 观众 32”
- r'”user_count[“\s]*:\s*(\d+)’,
- r'”viewer_count[“\s]*:\s*(\d+)’,
- ]
- for pattern in patterns:
- matches = re.findall(pattern, page_content, re.IGNORECASE)
- if matches:
- try:
- count_str = matches[0].replace(‘,’, ”) # 移除千分位逗号
- count = int(count_str)
- room_info[‘online_count’] = count
- room_info[‘viewer_count_text’] = f”{count}人在线 ”
- logger.info(f” 通过正则表达式获取到人数: {room_info[‘online_count’]}”)
- break
- except ValueError:
- continue
- except Exception as e:
- logger.warning(f” 页面内容解析失败: {e}”)
- context.close()
- # 如果获取到了有效信息就返回
- if room_info[‘online_count’] > 0 or room_info[‘stream_url’] or room_info[‘is_live’]:
- logger.info(f” 成功获取直播间信息: 在线人数 ={room_info[‘online_count’]}, 直播状态 ={room_info[‘is_live’]}”)
- return room_info
- except Exception as e:
- logger.error(f” 获取直播间信息失败 (尝试 {attempt + 1}): {str(e)}”)
- if attempt < max_retries – 1:
- time.sleep(2)
- continue
- logger.error(f” 所有 {max_retries} 次尝试均失败,无法获取直播间信息 ”)
- return room_info
- @app.route(‘/’)
- @handle_exceptions
- def home():
- return jsonify({
- ‘message’: ‘ 抖音直播解析后端服务已启动 ’,
- ‘api’: [‘/api/parse’, ‘/api/room-info’, ‘/api/monitor’, ‘/api/record/start’, ‘/api/record/stop’, ‘/api/record/status’]
- })
- @app.route(‘/api/parse’, methods=[‘POST’])
- @handle_exceptions
- def parse_live_stream():
- data = request.get_json()
- url = data.get(‘url’)
- if not url:
- return jsonify({
- ‘success’: False,
- ‘message’: ‘ 无效的直播链接或主播 ID’
- })
- # 处理不同格式的输入
- processed_url = url.strip()
- logger.info(f” 收到解析请求,原始输入: {processed_url}”)
- # 1. 检查是否是纯数字(主播 ID)
- if re.match(r’^\d+$’, processed_url):
- logger.info(f” 检测到主播 ID 格式: {processed_url}”)
- room_id = processed_url
- full_url = f”https://live.douyin.com/{room_id}”
- # 2. 检查是否是完整的抖音直播 URL
- elif “douyin.com” in processed_url:
- logger.info(f” 检测到抖音 URL 格式: {processed_url}”)
- # 提取房间号
- if “/user/” in processed_url:
- # 用户主页 URL
- logger.info(“ 检测到用户主页 URL,尝试提取用户 ID”)
- user_id_match = re.search(r’/user/([^/?]+)’, processed_url)
- if user_id_match:
- room_id = user_id_match.group(1)
- full_url = f”https://live.douyin.com/{room_id}”
- else:
- return jsonify({
- ‘success’: False,
- ‘message’: ‘ 无法从用户主页 URL 提取用户 ID’
- })
- else:
- # 直播间 URL
- room_id_match = re.search(r’live\.douyin\.com/([^/?]+)’, processed_url)
- if room_id_match:
- room_id = room_id_match.group(1)
- full_url = f”https://live.douyin.com/{room_id}”
- else:
- # 尝试直接使用
- room_id = processed_url
- full_url = processed_url
- # 3. 其他格式(可能是短链接或其他标识符)
- else:
- logger.info(f” 未识别的 URL 格式,尝试直接使用: {processed_url}”)
- room_id = processed_url
- full_url = processed_url
- logger.info(f” 处理后的房间 ID: {room_id}, 完整 URL: {full_url}”)
- # 调用解析函数获取直播流地址
- real_stream_url = get_real_stream_url(full_url)
- if real_stream_url:
- logger.info(f” 成功解析直播流地址: {real_stream_url}”)
- return jsonify({
- ‘success’: True,
- ‘streamUrl’: real_stream_url,
- ‘roomId’: room_id,
- ‘fullUrl’: full_url
- })
- else:
- logger.warning(f” 无法解析直播流地址,输入: {processed_url}”)
- return jsonify({
- ‘success’: False,
- ‘message’: ‘ 无法解析直播链接,请确认主播是否开播 ’
- })
- # 新增:获取直播间详细信息的 API 接口
- @app.route(‘/api/room-info’, methods=[‘POST’])
- @handle_exceptions
- def get_room_info():
- “”” 获取直播间详细信息,包括在线人数 ”””
- data = request.get_json()
- url = data.get(‘url’)
- if not url:
- return jsonify({
- ‘success’: False,
- ‘message’: ‘ 无效的直播链接或主播 ID’
- })
- # 处理 URL 格式
- processed_url = url.strip()
- logger.info(f” 收到直播间信息请求: {processed_url}”)
- # URL 格式处理逻辑(与 parse_live_stream 相同)
- if re.match(r’^\d+$’, processed_url):
- full_url = f”https://live.douyin.com/{processed_url}”
- elif “douyin.com” in processed_url:
- full_url = processed_url
- else:
- full_url = processed_url
- # 获取直播间信息
- room_info = get_live_room_info(full_url)
- if room_info[‘is_live’] or room_info[‘online_count’] > 0:
- return jsonify({
- ‘success’: True,
- ‘data’: {
- ‘online_count’: room_info[‘online_count’],
- ‘viewer_count_text’: room_info[‘viewer_count_text’],
- ‘is_live’: room_info[‘is_live’],
- ‘stream_url’: room_info[‘stream_url’],
- ‘room_title’: room_info[‘room_title’],
- ‘anchor_name’: room_info[‘anchor_name’],
- ‘room_id’: room_info[‘room_id’]
- }
- })
- else:
- return jsonify({
- ‘success’: False,
- ‘message’: ‘ 直播间未开播或无法获取信息 ’,
- ‘data’: room_info
- })
- def get_anchor_info(anchor_id, max_retries=2):
- “””
- 获取主播信息(名字、直播状态等)
- :param anchor_id: 主播 ID
- :param max_retries: 最大重试次数
- :return: dict 包含 {“is_live”: bool, “name”: str, “title”: str}
- “””
- for attempt in range(max_retries):
- try:
- from playwright.sync_api import sync_playwright
- import random
- with sync_playwright() as p:
- # 启动浏览器(无头模式)
- browser = p.chromium.launch(headless=True)
- context = browser.new_context(
- 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″,
- extra_http_headers={
- “Referer”: “https://www.douyin.com/”,
- “Accept-Language”: “zh-CN,zh;q=0.9”
- },
- viewport={“width”: 1920, “height”: 1080},
- java_script_enabled=True
- )
- page = context.new_page()
- # 随机延迟(1- 3 秒),模拟人类操作
- time.sleep(random.uniform(1, 3))
- # 访问直播间页面
- try:
- # 处理 URL 格式,确保不重复添加域名
- if anchor_id.startswith(“https://live.douyin.com/”):
- url = anchor_id
- room_id = anchor_id.split(“/”)[-1]
- else:
- url = f”https://live.douyin.com/{anchor_id}”
- room_id = anchor_id
- logger.info(f”[尝试{attempt + 1}] 开始访问直播间页面: {url}”)
- page.goto(url, timeout=30000, wait_until=”domcontentloaded”)
- logger.info(f”[尝试{attempt + 1}] 成功访问直播间页面 ”)
- except Exception as e:
- if “Timeout” in str(e):
- logger.warning(f”[尝试{attempt + 1}] 页面加载超时,继续处理 ”)
- else:
- logger.error(f”[尝试{attempt + 1}] 访问直播间页面失败: {e}”)
- context.close()
- continue
- # 等待页面加载
- try:
- logger.info(f”[尝试{attempt + 1}] 等待页面关键元素加载 …”)
- page.wait_for_selector(“body”, timeout=10000)
- # 额外等待,确保页面完全加载
- time.sleep(3)
- except Exception as wait_e:
- logger.warning(f”[尝试{attempt + 1}] 等待元素失败: {wait_e},继续处理 ”)
- # 获取页面内容
- content = page.content()
- logger.info(f”[尝试{attempt + 1}] 页面内容长度: {len(content)} 字符 ”)
- # 提取主播信息
- anchor_info = {
- “is_live”: False,
- “name”: f”anchor_{room_id}”, # 默认名字
- “title”: “”
- }
- # 尝试获取主播名字
- logger.info(f”[尝试{attempt + 1}] 开始尝试获取主播名字 …”)
- # 策略 1: 尝试从页面标题获取(优先策略)
- try:
- title = page.title()
- logger.info(f”[尝试{attempt + 1}] 页面标题: {title}”)
- # 抖音直播间标题格式分析
- if title and title != “ 抖音直播 ”:
- # 格式 1: “ 主播名字的直播间 ”
- if “ 的直播间 ” in title:
- name_from_title = title.split(“ 的直播间 ”)[0].strip()
- if name_from_title and len(name_from_title) < 50 and name_from_title != room_id:
- anchor_info[“name”] = name_from_title
- logger.info(f”[尝试{attempt + 1}] 从页面标题获取到主播名字: {name_from_title}”)
- # 格式 2: “ 主播名字 – 抖音直播 ”
- elif ” – 抖音 ” in title or ” – 直播 ” in title:
- parts = title.split(” – “)
- if len(parts) > 0:
- potential_name = parts[0].strip()
- if potential_name and len(potential_name) < 50 and potential_name != room_id:
- anchor_info[“name”] = potential_name
- logger.info(f”[尝试{attempt + 1}] 从页面标题解析到主播名字: {potential_name}”)
- # 格式 3: “ 主播名字正在直播 ”
- elif “ 正在直播 ” in title:
- name_from_title = title.replace(“ 正在直播 ”, “”).strip()
- if name_from_title and len(name_from_title) < 50 and name_from_title != room_id:
- anchor_info[“name”] = name_from_title
- logger.info(f”[尝试{attempt + 1}] 从 ’ 正在直播 ’ 标题获取到主播名字: {name_from_title}”)
- # 格式 4: 直接使用标题(如果长度合理)
- elif len(title) < 50 and title != room_id and not any(word in title.lower() for word in [“douyin”, “live”, “ 直播 ”]):
- anchor_info[“name”] = title
- logger.info(f”[尝试{attempt + 1}] 直接使用页面标题作为主播名字: {title}”)
- except Exception as title_e:
- logger.debug(f”[尝试{attempt + 1}] 从标题获取名字失败: {title_e}”)
- # 策略 2: 尝试从页面元素获取(如果标题没有找到合适的名字)
- if anchor_info[“name”] == f”anchor_{room_id}”:
- try:
- logger.info(f”[尝试{attempt + 1}] 尝试从页面元素获取主播名字 …”)
- # 更新的选择器列表
- name_selectors = [
- “[data-e2e=’living-avatar-name’]”,
- “[data-e2e=’user-info-name’]”,
- “.webcast-avatar-info__name”,
- “.live-user-info .name”,
- “.live-user-name”,
- “.user-name”,
- “.anchor-name”,
- “[class*=’name’]”,
- “h3”,
- “.nickname”
- ]
- for selector in name_selectors:
- try:
- name_element = page.query_selector(selector)
- if name_element:
- name_text = name_element.inner_text().strip()
- if name_text and len(name_text) < 50 and name_text != room_id and not name_text.isdigit():
- anchor_info[“name”] = name_text
- logger.info(f”[尝试{attempt + 1}] 使用选择器 {selector} 获取到主播名字: {name_text}”)
- break
- except Exception as sel_e:
- logger.debug(f”[尝试{attempt + 1}] 选择器 {selector} 失败: {sel_e}”)
- continue
- except Exception as e:
- logger.debug(f”[尝试{attempt + 1}] 从页面元素获取名字失败: {e}”)
- # 策略 3: 从页面 JSON 数据中提取(如果前面都没找到)
- if anchor_info[“name”] == f”anchor_{room_id}”:
- try:
- logger.info(f”[尝试{attempt + 1}] 尝试从页面 JSON 数据获取主播名字 …”)
- content_text = page.content()
- # 多种 JSON 字段模式
- json_patterns = [
- r'”nickname”\s*:\s*”([^”]+)”‘,
- r'”displayName”\s*:\s*”([^”]+)”‘,
- r'”userName”\s*:\s*”([^”]+)”‘,
- r'”ownerName”\s*:\s*”([^”]+)”‘,
- r'”anchorName”\s*:\s*”([^”]+)”‘,
- r'”user_name”\s*:\s*”([^”]+)”‘,
- r'”anchor_info”[^}]*”nickname”\s*:\s*”([^”]+)”‘
- ]
- import re as regex_re
- for pattern in json_patterns:
- matches = regex_re.findall(pattern, content_text)
- for match in matches:
- if match and len(match) < 50 and match != room_id and not match.isdigit():
- # 过滤掉明显不是名字的内容
- if not any(word in match.lower() for word in [‘http’, ‘www’, ‘.com’, ‘live’, ‘stream’]):
- anchor_info[“name”] = match
- logger.info(f”[尝试{attempt + 1}] 从页面 JSON 数据获取到主播名字: {match} (模式: {pattern})”)
- break
- if anchor_info[“name”] != f”anchor_{room_id}”:
- break
- except Exception as content_e:
- logger.debug(f”[尝试{attempt + 1}] 从页面内容获取名字失败: {content_e}”)
- # 策略 4: 最后的降级处理(使用更友好的默认名字)
- if anchor_info[“name”] == f”anchor_{room_id}”:
- # 尝试从 room_id 中提取可能的用户名部分
- if len(room_id) > 8: # 如果 room_id 足够长,尝试截取前 8 位作为更简洁的标识
- anchor_info[“name”] = f” 主播{room_id[:8]}”
- else:
- anchor_info[“name”] = f” 主播{room_id}”
- logger.info(f”[尝试{attempt + 1}] 使用降级处理的默认名字: {anchor_info[‘name’]}”)
- # 检查直播状态
- stream_urls = []
- def handle_response(response):
- url = response.url
- if ((url.endswith(‘.flv’) or url.endswith(‘.m3u8’)) and
- not url.endswith(‘.mp4’) and
- (‘pull-‘ in url or ‘douyincdn.com’ in url)):
- stream_urls.append(url)
- logger.info(f”[尝试{attempt + 1}] 捕获到直播流: {url}”)
- page.on(“response”, handle_response)
- # 等待更多网络请求
- logger.info(f”[尝试{attempt + 1}] 等待网络请求 …”)
- time.sleep(3)
- # 多种方式检测直播状态
- anchor_info[“is_live”] = (
- “ 直播中 ” in content or
- “ 正在直播 ” in content or
- “live_no_stream” not in content.lower() and “ 直播 ” in content or
- “live” in content.lower() or
- page.query_selector(“.webcast-chatroom___enter-done”) is not None or
- page.query_selector(“.live-room”) is not None or
- page.query_selector(“video[src*=’.m3u8′]”) is not None or
- page.query_selector(“video[src*=’.flv’]”) is not None or
- page.query_selector(“video[src*=’douyincdn.com’]”) is not None or
- len(stream_urls) > 0
- )
- context.close()
- logger.info(f”[尝试{attempt + 1}] 最终获取结果 – 主播名字: {anchor_info[‘name’]}, 直播状态: {‘ 在线 ’ if anchor_info[‘is_live’] else ‘ 离线 ’}”)
- return anchor_info
- except Exception as e:
- logger.error(f” 获取主播信息失败 (尝试 {attempt + 1}): {str(e)}”)
- if attempt < max_retries – 1:
- time.sleep(2)
- continue
- logger.error(f” 所有 {max_retries} 次尝试均失败,返回默认结果 ”)
- # 最终降级处理
- fallback_name = f” 主播{anchor_id[:8]}” if len(str(anchor_id)) > 8 else f” 主播{anchor_id}”
- return {“is_live”: False, “name”: fallback_name, “title”: “”}
- def check_anchor_status(anchor_id, max_retries=2):
- “””
- 检查主播是否开播
- :param anchor_id: 主播 ID
- :param max_retries: 最大重试次数
- :return: True(开播)/False(未开播)
- “””
- for attempt in range(max_retries):
- try:
- from playwright.sync_api import sync_playwright
- import random
- with sync_playwright() as p:
- # 启动浏览器(无头模式)
- browser = p.chromium.launch(headless=True)
- context = browser.new_context(
- 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″,
- extra_http_headers={
- “Referer”: “https://www.douyin.com/”,
- “Accept-Language”: “zh-CN,zh;q=0.9”
- },
- viewport={“width”: 1920, “height”: 1080},
- java_script_enabled=True
- )
- page = context.new_page()
- # 随机延迟(1- 3 秒),模拟人类操作
- time.sleep(random.uniform(1, 3))
- # 访问直播间页面
- try:
- # 处理 URL 格式,确保不重复添加域名
- if anchor_id.startswith(“https://live.douyin.com/”):
- url = anchor_id
- room_id = anchor_id.split(“/”)[-1]
- else:
- url = f”https://live.douyin.com/{anchor_id}”
- room_id = anchor_id
- page.goto(url, timeout=30000, wait_until=”domcontentloaded”)
- logger.info(f” 成功访问直播间页面: {url}”)
- except Exception as e:
- if “Timeout” in str(e):
- logger.warning(f” 页面加载超时,继续处理 ”)
- else:
- logger.error(f” 访问直播间页面失败: {e}”)
- context.close()
- continue
- # 等待页面加载
- try:
- page.wait_for_selector(“video, .live-room, .webcast-chatroom”, timeout=10000)
- except:
- logger.warning(“ 未找到关键元素,继续处理 ”)
- # 获取页面内容
- content = page.content()
- # 检查直播状态
- stream_urls = []
- def handle_response(response):
- url = response.url
- if ((url.endswith(‘.flv’) or url.endswith(‘.m3u8’)) and
- not url.endswith(‘.mp4’) and
- (‘pull-‘ in url or ‘douyincdn.com’ in url)):
- stream_urls.append(url)
- logger.info(f” 捕获到直播流: {url}”)
- page.on(“response”, handle_response)
- # 等待更多网络请求
- time.sleep(3)
- # 多种方式检测直播状态
- is_live = (
- “ 直播中 ” in content or
- “ 正在直播 ” in content or
- “ 直播 ” in content or
- “live” in content.lower() or
- page.query_selector(“.webcast-chatroom___enter-done”) is not None or
- page.query_selector(“.live-room”) is not None or
- page.query_selector(“video[src*=’.m3u8′]”) is not None or
- page.query_selector(“video[src*=’.flv’]”) is not None or
- page.query_selector(“video[src*=’douyincdn.com’]”) is not None or
- len(stream_urls) > 0 or
- any(“live.douyin.com” in url for url in stream_urls)
- )
- context.close()
- if is_live:
- logger.info(f” 主播 {anchor_id} 正在直播 ”)
- if stream_urls:
- logger.info(f” 捕获到直播流地址: {stream_urls[0]}”)
- else:
- logger.info(f” 主播 {anchor_id} 未开播 ”)
- return is_live
- except Exception as e:
- logger.error(f” 检查主播状态失败 (尝试 {attempt + 1}): {str(e)}”)
- if attempt < max_retries – 1:
- time.sleep(2)
- continue
- logger.error(f” 所有 {max_retries} 次尝试均失败 ”)
- return False
- @app.route(‘/api/monitor’, methods=[‘POST’])
- @handle_exceptions
- def monitor_live_stream():
- data = request.get_json()
- anchor_id = data.get(‘anchor_id’)
- max_wait_minutes = data.get(‘max_wait’, 5) # 默认最多等待 5 分钟
- check_interval = data.get(‘interval’, 30) # 默认每 30 秒检查一次
- logger.info(f” 收到监控请求,主播 ID: {anchor_id}, 最长等待: {max_wait_minutes}分钟, 轮询地址: https://live.douyin.com/{anchor_id}”)
- if not anchor_id:
- logger.warning(“ 无效的主播 ID”)
- return jsonify({
- ‘success’: False,
- ‘message’: ‘ 无效的主播 ID’
- })
- max_checks = (max_wait_minutes * 60) // check_interval
- checks_done = 0
- # 轮询检查主播状态
- while checks_done < max_checks:
- checks_done += 1
- logger.info(f” 第 {checks_done}/{max_checks} 次检查主播 {anchor_id} 状态 ”)
- is_live = check_anchor_status(anchor_id)
- if is_live:
- logger.info(f” 主播 {anchor_id} 正在直播,开始解析直播流地址 ”)
- # 获取直播流地址
- stream_url = get_real_stream_url(f”https://live.douyin.com/{anchor_id}”)
- if stream_url:
- logger.info(f” 成功获取直播流地址: {stream_url}”)
- return jsonify({
- ‘success’: True,
- ‘status’: ‘live’,
- ‘streamUrl’: stream_url,
- ‘checks_performed’: checks_done
- })
- else:
- logger.warning(“ 无法解析直播流地址 ”)
- return jsonify({
- ‘success’: False,
- ‘message’: ‘ 无法解析直播流地址 ’,
- ‘checks_performed’: checks_done
- })
- else:
- logger.info(f” 主播 {anchor_id} 未开播,等待下一次检查 ”)
- # 如果达到最大检查次数,返回未开播状态
- if checks_done >= max_checks:
- logger.info(f” 监控超时,主播 {anchor_id} 在 {max_wait_minutes} 分钟内未开播 ”)
- return jsonify({
- ‘success’: True,
- ‘status’: ‘not_live’,
- ‘message’: f’ 主播在 {max_wait_minutes} 分钟内未开播 ’,
- ‘checks_performed’: checks_done
- })
- time.sleep(check_interval)
- logger.warning(“ 监控循环异常结束 ”)
- return jsonify({
- ‘success’: False,
- ‘message’: ‘ 监控异常结束 ’,
- ‘checks_performed’: checks_done
- })
- class MultiRoomPoller:
- “”” 多直播间轮询管理器 ”””
- def __init__(self):
- self.polling_rooms = {} # 存储轮询中的直播间
- self.polling_history = [] # 存储历史轮询记录
- self.lock = threading.Lock()
- self.running = True
- self.max_history_records = 1000 # 最大历史记录数
- self.rooms_file = ‘saved_rooms.json’ # 本地存储文件
- self.history_file = ‘rooms_history.json’ # 历史记录文件
- # 启动时加载已保存的直播间
- self._load_rooms_from_file()
- self._load_history_from_file()
- def add_room(self, room_id, room_url, check_interval=60, auto_record=False):
- “”” 添加直播间到轮询列表 ”””
- with self.lock:
- if room_id not in self.polling_rooms:
- # 不再在这里添加历史记录,等待轮询线程获取到真实主播名字后再添加
- self.polling_rooms[room_id] = {
- ‘room_url’: room_url,
- ‘room_id’: room_id,
- ‘check_interval’: check_interval,
- ‘auto_record’: auto_record,
- ‘status’: ‘waiting’, # waiting, checking, live, offline, paused
- ‘last_check’: None,
- ‘stream_url’: None,
- ‘recording_session_id’: None,
- ‘thread’: None,
- ‘anchor_name’: f’anchor_{room_id}’, # 新增:主播名字
- ‘live_title’: ”, # 新增:直播标题
- ‘added_time’: datetime.now(), # 新增:添加时间
- ‘history_added’: False, # 新增:标记是否已添加历史记录
- ‘online_count’: 0, # 新增:在线人数
- ‘viewer_count_text’: ” # 新增:观看人数文本
- }
- # 启动轮询线程
- thread = threading.Thread(
- target=self._poll_room,
- args=(room_id,),
- daemon=True
- )
- thread.start()
- self.polling_rooms[room_id][‘thread’] = thread
- logger.info(f” 已添加直播间 {room_id} 到轮询列表 ”)
- # 保存到本地文件
- self._save_rooms_to_file()
- return True
- else:
- logger.warning(f” 直播间 {room_id} 已在轮询列表中 ”)
- return False
- def remove_room(self, room_id):
- “”” 从轮询列表移除直播间 ”””
- with self.lock:
- if room_id in self.polling_rooms:
- room_info = self.polling_rooms[room_id]
- # 记录到历史
- self._add_to_history(
- room_id,
- room_info[‘room_url’],
- ”,
- ”,
- room_info.get(‘anchor_name’, f’anchor_{room_id}’)
- )
- # 停止录制(如果正在录制)
- if self.polling_rooms[room_id][‘recording_session_id’]:
- self._stop_recording(room_id)
- # 标记线程停止
- self.polling_rooms[room_id][‘status’] = ‘stopped’
- del self.polling_rooms[room_id]
- # 保存到本地文件
- self._save_rooms_to_file()
- logger.info(f” 已从轮询列表移除直播间 {room_id}”)
- return True
- return False
- def pause_room(self, room_id):
- “”” 暂停指定直播间的轮询 ”””
- with self.lock:
- if room_id in self.polling_rooms:
- # 如果已经在暂停状态,返回 False
- if self.polling_rooms[room_id][‘status’] == ‘paused’:
- return False
- # 更新状态为暂停
- self.polling_rooms[room_id][‘status’] = ‘paused’
- logger.info(f” 已暂停直播间 {room_id} 的轮询 ”)
- return True
- return False
- def resume_room(self, room_id):
- “”” 恢复指定直播间的轮询 ”””
- with self.lock:
- if room_id in self.polling_rooms:
- # 如果不在暂停状态,返回 False
- if self.polling_rooms[room_id][‘status’] != ‘paused’:
- return False
- # 更新状态为等待
- self.polling_rooms[room_id][‘status’] = ‘waiting’
- logger.info(f” 已恢复直播间 {room_id} 的轮询 ”)
- return True
- return False
- def _poll_room(self, room_id):
- “”” 单个直播间轮询逻辑 ”””
- while self.running:
- try:
- with self.lock:
- if room_id not in self.polling_rooms:
- break
- room_info = self.polling_rooms[room_id]
- # 检查是否暂停
- if room_info[‘status’] == ‘paused’:
- # 如果暂停,等待一段时间后继续检查
- time.sleep(5)
- continue
- if room_info[‘status’] == ‘stopped’:
- break
- # 更新状态为检查中
- with self.lock:
- self.polling_rooms[room_id][‘status’] = ‘checking’
- self.polling_rooms[room_id][‘last_check’] = datetime.now()
- # 检查直播状态并获取主播信息
- anchor_info = get_anchor_info(room_info[‘room_id’])
- is_live = anchor_info[‘is_live’]
- # 获取直播间详细信息(包括在线人数)
- room_detail_info = {‘online_count’: 0, ‘viewer_count_text’: ”}
- if is_live:
- try:
- # 调用 get_live_room_info 获取在线人数信息
- room_detail_info = get_live_room_info(room_info[‘room_url’])
- logger.info(f” 直播间 {room_id} 在线人数: {room_detail_info.get(‘online_count’, 0)}”)
- except Exception as e:
- logger.warning(f” 获取直播间 {room_id} 在线人数失败: {e}”)
- # 更新主播信息和在线人数
- with self.lock:
- self.polling_rooms[room_id][‘anchor_name’] = anchor_info[‘name’]
- self.polling_rooms[room_id][‘live_title’] = anchor_info[‘title’]
- self.polling_rooms[room_id][‘online_count’] = room_detail_info.get(‘online_count’, 0)
- self.polling_rooms[room_id][‘viewer_count_text’] = room_detail_info.get(‘viewer_count_text’, ”)
- # 如果还没有添加历史记录,现在添加一条记录
- if not self.polling_rooms[room_id].get(‘history_added’, False):
- self._add_to_history(
- room_id,
- room_info[‘room_url’],
- ”,
- ”,
- anchor_info[‘name’]
- )
- self.polling_rooms[room_id][‘history_added’] = True
- if is_live:
- logger.info(f” 检测到直播间 {room_id} 正在直播 ”)
- # 记录状态变化到历史(如果之前不是直播状态)
- # 简化版:不记录状态变化
- # 解析直播流地址
- stream_url = get_real_stream_url(room_info[‘room_url’])
- if stream_url:
- with self.lock:
- self.polling_rooms[room_id][‘status’] = ‘live’
- self.polling_rooms[room_id][‘stream_url’] = stream_url
- # 如果启用自动录制且未在录制
- if (room_info[‘auto_record’] and
- not room_info[‘recording_session_id’]):
- self._start_recording(room_id, stream_url)
- # 简化版:不记录自动录制开始
- else:
- logger.warning(f” 直播间 {room_id} 在线但无法获取流地址 ”)
- with self.lock:
- old_status = self.polling_rooms[room_id][‘status’]
- self.polling_rooms[room_id][‘status’] = ‘live_no_stream’
- # 简化版:不记录状态变化
- # 如果之前在录制,停止录制(直播结束无流)
- if room_info[‘recording_session_id’]:
- self._stop_recording(room_id)
- logger.info(f” 直播间 {room_id} 直播结束无流,已停止录制 ”)
- # 简化版:不记录停止录制
- else:
- # 直播间离线
- with self.lock:
- old_status = self.polling_rooms[room_id][‘status’]
- self.polling_rooms[room_id][‘status’] = ‘offline’
- self.polling_rooms[room_id][‘stream_url’] = None
- # 简化版:不记录状态变化
- # 如果之前在录制,停止录制
- if room_info[‘recording_session_id’]:
- self._stop_recording(room_id)
- logger.info(f” 直播间 {room_id} 离线,已停止录制 ”)
- # 简化版:不记录停止录制
- # 等待下次检查
- time.sleep(room_info[‘check_interval’])
- except Exception as e:
- logger.error(f” 轮询直播间 {room_id} 异常: {str(e)}”)
- with self.lock:
- if room_id in self.polling_rooms:
- self.polling_rooms[room_id][‘status’] = ‘error’
- time.sleep(30) # 出错时等待 30 秒后重试
- def _start_recording(self, room_id, stream_url):
- “”” 启动录制 ”””
- try:
- # 获取主播名字用于文件命名
- with self.lock:
- anchor_name = self.polling_rooms[room_id].get(‘anchor_name’, f’anchor_{room_id}’)
- # 清理文件名中的非法字符
- safe_anchor_name = re.sub(r'[<>:”/\|?*]’, ‘_’, anchor_name)
- session_id = f”auto_record_{room_id}_{int(time.time())}”
- timestamp = datetime.now().strftime(‘%Y%m%d_%H%M%S’)
- # 使用主播名字命名文件
- output_path = f”recordings/{safe_anchor_name}_{timestamp}.mp4″
- # 启动录制线程
- thread = threading.Thread(
- target=record_stream,
- args=(stream_url, output_path, session_id),
- daemon=True
- )
- thread.start()
- with self.lock:
- self.polling_rooms[room_id][‘recording_session_id’] = session_id
- logger.info(f” 已为主播 {anchor_name} (房间 {room_id}) 启动自动录制,会话 ID: {session_id},文件: {output_path}”)
- except Exception as e:
- logger.error(f” 启动直播间 {room_id} 录制失败: {str(e)}”)
- def _stop_recording(self, room_id):
- “”” 停止录制 ”””
- try:
- with self.lock:
- session_id = self.polling_rooms[room_id][‘recording_session_id’]
- if session_id:
- self.polling_rooms[room_id][‘recording_session_id’] = None
- if session_id:
- # 停止录制会话
- with recording_lock:
- if session_id in recording_sessions:
- session = recording_sessions[session_id]
- if session[‘process’]:
- session[‘process’].terminate()
- session[‘status’] = ‘stopped’
- session[‘end_time’] = datetime.now()
- logger.info(f” 已停止直播间 {room_id} 的录制,会话 ID: {session_id}”)
- except Exception as e:
- logger.error(f” 停止直播间 {room_id} 录制失败: {str(e)}”)
- def get_status(self):
- “”” 获取所有轮询状态 ”””
- with self.lock:
- # 过滤掉不能 JSON 序列化的对象(如 Thread)
- status = {}
- for room_id, room_info in self.polling_rooms.items():
- status[room_id] = {
- ‘room_url’: room_info[‘room_url’],
- ‘room_id’: room_info[‘room_id’],
- ‘check_interval’: room_info[‘check_interval’],
- ‘auto_record’: room_info[‘auto_record’],
- ‘status’: room_info[‘status’],
- ‘last_check’: room_info[‘last_check’].isoformat() if room_info[‘last_check’] else None,
- ‘stream_url’: room_info[‘stream_url’],
- ‘recording_session_id’: room_info[‘recording_session_id’],
- ‘anchor_name’: room_info.get(‘anchor_name’, f’anchor_{room_id}’), # 新增:主播名字
- ‘live_title’: room_info.get(‘live_title’, ”), # 新增:直播标题
- ‘added_time’: room_info.get(‘added_time’).isoformat() if room_info.get(‘added_time’) else None, # 新增:添加时间
- ‘online_count’: room_info.get(‘online_count’, 0), # 新增:在线人数
- ‘viewer_count_text’: room_info.get(‘viewer_count_text’, ”) # 新增:观看人数文本
- # 注意:我们不包含 ‘thread’ 字段,因为它不能 JSON 序列化
- }
- return status
- def _add_to_history(self, room_id, room_url, action, description, anchor_name=None):
- “”” 添加记录到历史(简化版,带去重功能)”””
- # 获取主播名字,优先使用参数,其次从房间信息中获取
- if not anchor_name:
- with self.lock:
- if room_id in self.polling_rooms:
- anchor_name = self.polling_rooms[room_id].get(‘anchor_name’, f’anchor_{room_id}’)
- else:
- anchor_name = f’anchor_{room_id}’
- # 检查是否已存在相同的链接(去重)
- existing_urls = {record[‘room_url’] for record in self.polling_history}
- if room_url in existing_urls:
- logger.info(f” 历史记录去重: 链接 {room_url} 已存在,跳过添加 ”)
- return
- history_record = {
- ‘id’: f”{room_id}_{int(time.time()*1000)}”, # 唯一 ID
- ‘anchor_name’: anchor_name,
- ‘room_url’: room_url,
- ‘timestamp’: datetime.now().isoformat(),
- ‘date’: datetime.now().strftime(‘%Y-%m-%d’),
- ‘time’: datetime.now().strftime(‘%H:%M:%S’)
- }
- # 添加到历史列表的开头(最新的在前面)
- self.polling_history.insert(0, history_record)
- # 保持历史记录数量在限制内
- if len(self.polling_history) > self.max_history_records:
- self.polling_history = self.polling_history[:self.max_history_records]
- # 保存历史记录到文件
- self._save_history_to_file()
- logger.info(f” 历史记录: {description} (房间 {room_id}),主播: {anchor_name}”)
- def get_history(self, limit=50, room_id=None, action=None):
- “”” 获取历史记录 ”””
- with self.lock:
- history = self.polling_history.copy()
- # 限制返回数量
- return history[:limit]
- def _save_rooms_to_file(self):
- “”” 保存直播间列表到文件 ”””
- try:
- rooms_data = {}
- for room_id, room_info in self.polling_rooms.items():
- rooms_data[room_id] = {
- ‘room_url’: room_info[‘room_url’],
- ‘check_interval’: room_info[‘check_interval’],
- ‘auto_record’: room_info[‘auto_record’],
- ‘anchor_name’: room_info.get(‘anchor_name’, f’anchor_{room_id}’),
- ‘added_time’: room_info[‘added_time’].isoformat() if room_info.get(‘added_time’) else datetime.now().isoformat()
- }
- with open(self.rooms_file, ‘w’, encoding=’utf-8′) as f:
- json.dump(rooms_data, f, ensure_ascii=False, indent=2)
- logger.info(f” 已保存 {len(rooms_data)} 个直播间到 {self.rooms_file}”)
- except Exception as e:
- logger.error(f” 保存直播间列表失败: {str(e)}”)
- def _load_rooms_from_file(self):
- “”” 从文件加载直播间列表 ”””
- try:
- if os.path.exists(self.rooms_file):
- with open(self.rooms_file, ‘r’, encoding=’utf-8′) as f:
- rooms_data = json.load(f)
- for room_id, room_info in rooms_data.items():
- # 使用加载的数据创建直播间信息
- self.polling_rooms[room_id] = {
- ‘room_url’: room_info[‘room_url’],
- ‘room_id’: room_id,
- ‘check_interval’: room_info.get(‘check_interval’, 60),
- ‘auto_record’: room_info.get(‘auto_record’, False),
- ‘status’: ‘waiting’,
- ‘last_check’: None,
- ‘stream_url’: None,
- ‘recording_session_id’: None,
- ‘thread’: None,
- ‘anchor_name’: room_info.get(‘anchor_name’, f’anchor_{room_id}’),
- ‘live_title’: ”,
- ‘added_time’: datetime.fromisoformat(room_info.get(‘added_time’, datetime.now().isoformat())),
- ‘history_added’: False, # 加载的房间也需要添加历史记录(如果能获取到真实主播名字)
- ‘online_count’: room_info.get(‘online_count’, 0), # 新增:在线人数
- ‘viewer_count_text’: room_info.get(‘viewer_count_text’, ”) # 新增:观看人数文本
- }
- # 启动轮询线程
- thread = threading.Thread(
- target=self._poll_room,
- args=(room_id,),
- daemon=True
- )
- thread.start()
- self.polling_rooms[room_id][‘thread’] = thread
- logger.info(f” 从 {self.rooms_file} 加载了 {len(rooms_data)} 个直播间 ”)
- else:
- logger.info(f” 直播间配置文件 {self.rooms_file} 不存在,将创建新文件 ”)
- except Exception as e:
- logger.error(f” 加载直播间列表失败: {str(e)}”)
- def _save_history_to_file(self):
- “”” 保存历史记录到文件 ”””
- try:
- with open(self.history_file, ‘w’, encoding=’utf-8′) as f:
- json.dump(self.polling_history, f, ensure_ascii=False, indent=2)
- logger.debug(f” 已保存历史记录到 {self.history_file}”)
- except Exception as e:
- logger.error(f” 保存历史记录失败: {str(e)}”)
- def _load_history_from_file(self):
- “”” 从文件加载历史记录(带去重功能)”””
- try:
- if os.path.exists(self.history_file):
- with open(self.history_file, ‘r’, encoding=’utf-8′) as f:
- raw_history = json.load(f)
- # 去重处理:根据 room_url 去重,保留最新的记录
- seen_urls = set()
- deduped_history = []
- for record in raw_history:
- room_url = record.get(‘room_url’, ”)
- if room_url not in seen_urls:
- seen_urls.add(room_url)
- deduped_history.append(record)
- else:
- logger.debug(f” 去重: 跳过重复链接 {room_url}”)
- self.polling_history = deduped_history
- # 如果去重后数量有变化,保存文件
- if len(deduped_history) != len(raw_history):
- logger.info(f” 历史记录去重: 从 {len(raw_history)} 条去重到 {len(deduped_history)} 条 ”)
- self._save_history_to_file()
- logger.info(f” 从 {self.history_file} 加载了 {len(self.polling_history)} 条历史记录 ”)
- else:
- logger.info(f” 历史记录文件 {self.history_file} 不存在,将创建新文件 ”)
- except Exception as e:
- logger.error(f” 加载历史记录失败: {str(e)}”)
- def stop_all(self):
- “”” 停止所有轮询 ”””
- self.running = False
- with self.lock:
- for room_id in list(self.polling_rooms.keys()):
- self.remove_room(room_id)
- # 全局轮询管理器实例
- multi_poller = MultiRoomPoller()
- def record_stream(stream_url, output_path, session_id):
- “””
- 使用 FFmpeg 录制直播流(支持分段录制)
- :param stream_url: 直播流地址
- :param output_path: 输出文件路径(不含分段序号)
- :param session_id: 录制会话 ID
- “””
- try:
- logger.info(f” 开始录制会话 {session_id}: {stream_url}”)
- # 创建录制目录
- os.makedirs(os.path.dirname(output_path), exist_ok=True)
- # 更新录制会话状态
- with recording_lock:
- recording_sessions[session_id] = {
- ‘process’: None,
- ‘output_path’: output_path,
- ‘start_time’: datetime.now(),
- ‘stream_url’: stream_url,
- ‘status’: ‘recording’,
- ‘segments’: [],
- ‘current_segment’: 0
- }
- # 生成分段文件名模板
- base_name = output_path.rsplit(‘.’, 1)[0]
- segment_template = f”{base_name}_part%03d.mp4″
- logger.info(f” 录制会话 {session_id} 输出路径: {output_path}”)
- logger.info(f” 录制会话 {session_id} 分段模板: {segment_template}”)
- # 构建 FFmpeg 命令 – 使用正确的分段格式
- if stream_url.endswith(‘.m3u8’):
- cmd = [
- ‘ffmpeg’,
- ‘-i’, stream_url,
- ‘-c’, ‘copy’, # 复制流,不重新编码
- ‘-bsf:a’, ‘aac_adtstoasc’, # 音频流修复
- ‘-f’, ‘segment’, # 使用分段格式
- ‘-segment_time’, ‘1800’, # 30 分钟分段
- ‘-segment_format’, ‘mp4’, # 分段格式为 MP4
- ‘-reset_timestamps’, ‘1’, # 重置时间戳
- ‘-segment_list_flags’, ‘live’, # 实时分段列表
- segment_template # 分段文件名模板
- ]
- else:
- cmd = [
- ‘ffmpeg’,
- ‘-i’, stream_url,
- ‘-c’, ‘copy’, # 复制流,不重新编码
- ‘-f’, ‘segment’, # 使用分段格式
- ‘-segment_time’, ‘1800’, # 30 分钟分段
- ‘-segment_format’, ‘mp4’, # 分段格式为 MP4
- ‘-reset_timestamps’, ‘1’, # 重置时间戳
- ‘-segment_list_flags’, ‘live’, # 实时分段列表
- segment_template # 分段文件名模板
- ]
- logger.info(f”FFmpeg 命令: {‘ ‘.join(cmd)}”)
- # 执行录制
- process = subprocess.Popen(
- cmd,
- stdout=subprocess.PIPE,
- stderr=subprocess.PIPE,
- universal_newlines=True
- )
- # 更新录制会话状态
- with recording_lock:
- recording_sessions[session_id][‘process’] = process
- # 等待进程结束或手动停止
- stdout, stderr = process.communicate()
- # 更新最终状态
- with recording_lock:
- if session_id in recording_sessions:
- if process.returncode == 0:
- recording_sessions[session_id][‘status’] = ‘completed’
- logger.info(f” 录制会话 {session_id} 成功完成 ”)
- else:
- recording_sessions[session_id][‘status’] = ‘failed’
- recording_sessions[session_id][‘error’] = stderr
- logger.error(f” 录制会话 {session_id} 失败: {stderr}”)
- recording_sessions[session_id][‘end_time’] = datetime.now()
- except Exception as e:
- logger.error(f” 录制会话 {session_id} 异常: {str(e)}”)
- with recording_lock:
- if session_id in recording_sessions:
- recording_sessions[session_id][‘status’] = ‘failed’
- recording_sessions[session_id][‘error’] = str(e)
- recording_sessions[session_id][‘end_time’] = datetime.now()
- @app.route(‘/api/record/start’, methods=[‘POST’])
- @handle_exceptions
- def start_recording():
- “””
- 开始录制直播流
- “””
- data = request.get_json()
- stream_url = data.get(‘stream_url’)
- session_id = data.get(‘session_id’) or f”recording_{int(time.time())}”
- anchor_name = data.get(‘anchor_name’, ‘unknown_anchor’) # 新增:主播名字参数
- if not stream_url:
- return jsonify({
- ‘success’: False,
- ‘message’: ‘ 缺少直播流地址 ’
- })
- # 检查是否已在录制
- with recording_lock:
- if session_id in recording_sessions and recording_sessions[session_id][‘status’] == ‘recording’:
- return jsonify({
- ‘success’: False,
- ‘message’: ‘ 该会话已在录制中 ’
- })
- # 清理文件名中的非法字符
- safe_anchor_name = re.sub(r'[<>:”/\\|?*]’, ‘_’, anchor_name)
- # 生成输出文件路径(使用主播名字)
- timestamp = datetime.now().strftime(‘%Y%m%d_%H%M%S’)
- output_path = f”recordings/{safe_anchor_name}_{timestamp}.mp4″
- # 启动录制线程
- thread = threading.Thread(
- target=record_stream,
- args=(stream_url, output_path, session_id),
- daemon=True
- )
- thread.start()
- return jsonify({
- ‘success’: True,
- ‘session_id’: session_id,
- ‘output_path’: output_path,
- ‘message’: ‘ 录制已开始 ’
- })
- @app.route(‘/api/record/stop’, methods=[‘POST’])
- @handle_exceptions
- def stop_recording():
- “””
- 停止录制
- “””
- data = request.get_json()
- session_id = data.get(‘session_id’)
- if not session_id:
- return jsonify({
- ‘success’: False,
- ‘message’: ‘ 缺少会话 ID’
- })
- with recording_lock:
- if session_id not in recording_sessions:
- return jsonify({
- ‘success’: False,
- ‘message’: ‘ 找不到录制会话 ’
- })
- session = recording_sessions[session_id]
- if session[‘status’] != ‘recording’:
- return jsonify({
- ‘success’: False,
- ‘message’: f’ 会话状态为 {session[“status”]}, 无法停止 ’
- })
- # 终止 FFmpeg 进程
- try:
- session[‘process’].terminate()
- session[‘status’] = ‘stopped’
- session[‘end_time’] = datetime.now()
- logger.info(f” 已停止录制会话 {session_id}”)
- except Exception as e:
- logger.error(f” 停止录制会话 {session_id} 失败: {str(e)}”)
- return jsonify({
- ‘success’: False,
- ‘message’: f’ 停止录制失败: {str(e)}’
- })
- return jsonify({
- ‘success’: True,
- ‘message’: ‘ 录制已停止 ’
- })
- @app.route(‘/api/get_current_stream’, methods=[‘GET’])
- @handle_exceptions
- def get_current_stream():
- “””
- 获取当前最新的直播流地址
- “””
- import os
- stream_file = ‘current_stream.txt’
- if os.path.exists(stream_file):
- try:
- with open(stream_file, ‘r’, encoding=’utf-8′) as f:
- stream_url = f.read().strip()
- if stream_url:
- logger.info(f” 读取到当前直播流地址: {stream_url}”)
- return jsonify({
- ‘success’: True,
- ‘stream_url’: stream_url,
- ‘message’: ‘ 成功获取直播流地址 ’
- })
- else:
- return jsonify({
- ‘success’: False,
- ‘message’: ‘ 直播流文件为空 ’
- })
- except Exception as e:
- logger.error(f” 读取直播流文件失败: {str(e)}”)
- return jsonify({
- ‘success’: False,
- ‘message’: f’ 读取文件失败: {str(e)}’
- })
- else:
- return jsonify({
- ‘success’: False,
- ‘message’: ‘ 直播流文件不存在 ’
- })
- @app.route(‘/api/record/split’, methods=[‘POST’])
- @handle_exceptions
- def split_recording():
- “””
- 手动分段录制
- “””
- data = request.get_json()
- session_id = data.get(‘session_id’)
- if not session_id:
- return jsonify({
- ‘success’: False,
- ‘message’: ‘ 缺少会话 ID’
- })
- with recording_lock:
- if session_id not in recording_sessions:
- return jsonify({
- ‘success’: False,
- ‘message’: ‘ 找不到录制会话 ’
- })
- session = recording_sessions[session_id]
- if session[‘status’] != ‘recording’:
- return jsonify({
- ‘success’: False,
- ‘message’: f’ 会话状态为 {session[“status”]}, 无法分段 ’
- })
- # 向 FFmpeg 进程发送分割信号
- try:
- # FFmpeg 的 segment 功能会自动创建新分段,这里只需记录操作
- session[‘current_segment’] += 1
- logger.info(f” 已为录制会话 {session_id} 创建新分段 {session[‘current_segment’]}”)
- return jsonify({
- ‘success’: True,
- ‘message’: f’ 已创建新分段 {session[“current_segment”]}’,
- ‘segment_number’: session[‘current_segment’]
- })
- except Exception as e:
- logger.error(f” 分段录制会话 {session_id} 失败: {str(e)}”)
- return jsonify({
- ‘success’: False,
- ‘message’: f’ 分段失败: {str(e)}’
- })
- @app.route(‘/api/poll’, methods=[‘POST’])
- @handle_exceptions
- def poll_live_stream():
- data = request.get_json()
- live_url = data.get(‘live_url’)
- logger.info(f” 收到轮询请求,直播间地址: {live_url}”)
- # 检查 URL 是否有效
- if not live_url:
- logger.warning(“ 轮询请求中 URL 为空 ”)
- return jsonify({
- ‘success’: False,
- ‘message’: ‘ 直播间地址为空 ’
- })
- # 处理不同格式的输入
- processed_url = live_url.strip()
- # 1. 检查是否是纯数字(主播 ID)
- if re.match(r’^\d+$’, processed_url):
- logger.info(f” 检测到主播 ID 格式: {processed_url}”)
- room_id = processed_url
- full_url = f”https://live.douyin.com/{room_id}”
- # 2. 检查是否是完整的抖音直播 URL
- elif “douyin.com” in processed_url:
- logger.info(f” 检测到抖音 URL 格式: {processed_url}”)
- # 提取房间号
- room_id_match = re.search(r’live\.douyin\.com\/([^/?]+)’, processed_url)
- if room_id_match:
- room_id = room_id_match.group(1)
- full_url = f”https://live.douyin.com/{room_id}”
- else:
- # 尝试从 URL 路径中提取最后一部分
- url_parts = processed_url.split(‘/’)
- room_id = url_parts[-1] or url_parts[-2]
- full_url = processed_url
- # 3. 其他格式(可能是短链接或其他标识符)
- else:
- logger.info(f” 未识别的 URL 格式,尝试直接使用: {processed_url}”)
- room_id = processed_url
- full_url = processed_url
- logger.info(f” 处理后的房间 ID: {room_id}, 完整 URL: {full_url}”)
- # 检查主播是否开播
- try:
- is_live = check_anchor_status(room_id)
- # 如果检测为未开播,但用户确认已开播,增加额外检查
- if not is_live:
- logger.warning(f” 初步检测主播 {room_id} 未开播,进行二次验证 ”)
- # 增加等待时间
- time.sleep(5)
- # 再次检查
- is_live = check_anchor_status(room_id)
- # 如果检测到开播,尝试解析直播流地址
- stream_url = None
- if is_live:
- logger.info(f” 检测到主播 {room_id} 正在直播,开始解析直播流地址 ”)
- try:
- stream_url = get_real_stream_url(full_url)
- if stream_url:
- logger.info(f” 成功解析直播流地址: {stream_url}”)
- else:
- logger.warning(f” 无法解析直播流地址,但主播确实在直播 ”)
- except Exception as parse_error:
- logger.error(f” 解析直播流地址异常: {str(parse_error)}”)
- # 解析失败不影响轮询结果,只是记录日志
- logger.info(f” 最终轮询结果: 主播 {room_id} {‘ 正在直播 ’ if is_live else ‘ 未开播 ’}”)
- # 按照 API 接口规范返回数据
- response_data = {
- ‘success’: True,
- ‘message’: ‘ 轮询请求已处理 ’,
- ‘data’: {
- ‘live_url’: live_url,
- ‘is_live’: is_live,
- ‘room_id’: room_id,
- ‘full_url’: full_url
- }
- }
- # 如果解析到了直播流地址,添加到返回数据中
- if stream_url:
- response_data[‘data’][‘stream_url’] = stream_url
- return jsonify(response_data)
- except Exception as e:
- logger.error(f” 轮询处理异常: {str(e)}”)
- return jsonify({
- ‘success’: False,
- ‘message’: f’ 轮询处理异常: {str(e)}’,
- ‘live_url’: live_url
- })
- @app.route(‘/api/record/status’, methods=[‘GET’])
- @handle_exceptions
- def get_recording_status():
- “””
- 获取录制状态
- “””
- session_id = request.args.get(‘session_id’)
- if session_id:
- with recording_lock:
- if session_id in recording_sessions:
- session = recording_sessions[session_id]
- return jsonify({
- ‘success’: True,
- ‘session_id’: session_id,
- ‘status’: session[‘status’],
- ‘output_path’: session.get(‘output_path’),
- ‘start_time’: session.get(‘start_time’),
- ‘end_time’: session.get(‘end_time’),
- ‘stream_url’: session.get(‘stream_url’)
- })
- else:
- return jsonify({
- ‘success’: False,
- ‘message’: ‘ 找不到录制会话 ’
- })
- else:
- # 返回所有录制会话状态
- with recording_lock:
- sessions = {
- sid: {
- ‘status’: session[‘status’],
- ‘output_path’: session.get(‘output_path’),
- ‘start_time’: session.get(‘start_time’),
- ‘end_time’: session.get(‘end_time’),
- ‘stream_url’: session.get(‘stream_url’)
- }
- for sid, session in recording_sessions.items()
- }
- return jsonify({
- ‘success’: True,
- ‘sessions’: sessions
- })
- @app.route(‘/api/multi-poll/add’, methods=[‘POST’])
- @handle_exceptions
- def add_polling_room():
- “”” 添加直播间到轮询列表 ”””
- data = request.get_json()
- room_url = data.get(‘room_url’)
- room_id = data.get(‘room_id’)
- check_interval = data.get(‘check_interval’, 60) # 默认 60 秒检查一次
- auto_record = data.get(‘auto_record’, False) # 是否自动录制
- if not room_url:
- return jsonify({
- ‘success’: False,
- ‘message’: ‘ 缺少直播间地址 ’
- })
- # 如果没有提供 room_id,尝试从 URL 解析
- if not room_id:
- # 处理不同格式的输入
- processed_url = room_url.strip()
- logger.info(f” 尝试解析 URL: {processed_url}”)
- # 1. 检查是否是纯数字(主播 ID)
- if re.match(r’^\d+$’, processed_url):
- logger.info(f” 检测到主播 ID 格式: {processed_url}”)
- room_id = processed_url
- # 2. 检查是否是完整的抖音直播 URL
- elif “douyin.com” in processed_url:
- logger.info(f” 检测到抖音 URL 格式: {processed_url}”)
- # 尝试多种 URL 格式的解析
- # 格式 1: https://live.douyin.com/123456
- room_id_match = re.search(r’live\.douyin\.com/([^/?&#]+)’, processed_url)
- if room_id_match:
- room_id = room_id_match.group(1)
- logger.info(f” 从 live.douyin.com URL 提取房间 ID: {room_id}”)
- else:
- # 格式 2: https://www.douyin.com/user/MS4wLjABAAAA…
- user_id_match = re.search(r’/user/([^/?&#]+)’, processed_url)
- if user_id_match:
- room_id = user_id_match.group(1)
- logger.info(f” 从用户主页 URL 提取用户 ID: {room_id}”)
- else:
- # 格式 3: 尝试从 URL 路径中提取数字部分
- url_parts = processed_url.split(‘/’)
- for part in reversed(url_parts):
- if part and part != ” and not part.startswith(‘?’):
- # 移除可能的参数
- clean_part = part.split(‘?’)[0].split(‘#’)[0]
- if clean_part:
- # 如果是纯数字,直接使用
- if re.match(r’^\d+$’, clean_part):
- room_id = clean_part
- logger.info(f” 从 URL 路径提取房间 ID: {room_id}”)
- break
- # 否则使用完整的部分
- else:
- room_id = clean_part
- logger.info(f” 从 URL 路径提取标识符: {room_id}”)
- break
- if not room_id:
- return jsonify({
- ‘success’: False,
- ‘message’: f’ 无法从 URL 解析房间 ID: {processed_url}’
- })
- # 3. 其他格式(可能是短链接或其他标识符)
- else:
- logger.info(f” 未识别的 URL 格式,尝试直接使用: {processed_url}”)
- room_id = processed_url
- logger.info(f” 最终解析得到的房间 ID: {room_id}”)
- success = multi_poller.add_room(room_id, room_url, check_interval, auto_record)
- if success:
- return jsonify({
- ‘success’: True,
- ‘message’: f’ 已添加直播间 {room_id} 到轮询列表 ’,
- ‘room_id’: room_id
- })
- else:
- return jsonify({
- ‘success’: False,
- ‘message’: f’ 直播间 {room_id} 已在轮询列表中 ’
- })
- @app.route(‘/api/multi-poll/remove’, methods=[‘POST’])
- @handle_exceptions
- def remove_polling_room():
- “”” 从轮询列表移除直播间 ”””
- data = request.get_json()
- room_id = data.get(‘room_id’)
- if not room_id:
- return jsonify({
- ‘success’: False,
- ‘message’: ‘ 缺少房间 ID’
- })
- success = multi_poller.remove_room(room_id)
- if success:
- return jsonify({
- ‘success’: True,
- ‘message’: f’ 已移除直播间 {room_id}’
- })
- else:
- return jsonify({
- ‘success’: False,
- ‘message’: f’ 直播间 {room_id} 不在轮询列表中 ’
- })
- @app.route(‘/api/multi-poll/status’, methods=[‘GET’])
- @handle_exceptions
- def get_multi_polling_status():
- “”” 获取多直播间轮询状态 ”””
- status = multi_poller.get_status()
- return jsonify({
- ‘success’: True,
- ‘polling_rooms’: status,
- ‘total_rooms’: len(status)
- })
- @app.route(‘/api/multi-poll/history’, methods=[‘GET’])
- @handle_exceptions
- def get_polling_history():
- “”” 获取轮询历史记录 ”””
- # 获取查询参数
- limit = request.args.get(‘limit’, 50, type=int)
- room_id = request.args.get(‘room_id’)
- action = request.args.get(‘action’)
- # 限制 limit 的范围
- limit = min(max(1, limit), 200) # 限制在 1 -200 之间
- history = multi_poller.get_history(limit=limit, room_id=room_id, action=action)
- return jsonify({
- ‘success’: True,
- ‘history’: history,
- ‘total_records’: len(history),
- ‘filters’: {
- ‘limit’: limit,
- ‘room_id’: room_id,
- ‘action’: action
- }
- })
- @app.route(‘/api/multi-poll/start-record’, methods=[‘POST’])
- @handle_exceptions
- def start_manual_recording():
- “”” 手动为指定直播间启动录制 ”””
- data = request.get_json()
- room_id = data.get(‘room_id’)
- if not room_id:
- return jsonify({
- ‘success’: False,
- ‘message’: ‘ 缺少房间 ID’
- })
- status = multi_poller.get_status()
- if room_id not in status:
- return jsonify({
- ‘success’: False,
- ‘message’: f’ 直播间 {room_id} 不在轮询列表中 ’
- })
- room_info = status[room_id]
- if room_info[‘status’] != ‘live’ or not room_info[‘stream_url’]:
- return jsonify({
- ‘success’: False,
- ‘message’: f’ 直播间 {room_id} 当前不在直播或无流地址 ’
- })
- if room_info[‘recording_session_id’]:
- return jsonify({
- ‘success’: False,
- ‘message’: f’ 直播间 {room_id} 已在录制中 ’
- })
- # 启动录制
- multi_poller._start_recording(room_id, room_info[‘stream_url’])
- # 简化版:不记录手动录制
- return jsonify({
- ‘success’: True,
- ‘message’: f’ 已为直播间 {room_id} 启动录制 ’
- })
- @app.route(‘/api/multi-poll/stop-record’, methods=[‘POST’])
- @handle_exceptions
- def stop_manual_recording():
- “”” 手动停止指定直播间的录制 ”””
- data = request.get_json()
- room_id = data.get(‘room_id’)
- if not room_id:
- return jsonify({
- ‘success’: False,
- ‘message’: ‘ 缺少房间 ID’
- })
- status = multi_poller.get_status()
- if room_id not in status:
- return jsonify({
- ‘success’: False,
- ‘message’: f’ 直播间 {room_id} 不在轮询列表中 ’
- })
- room_info = status[room_id]
- if not room_info[‘recording_session_id’]:
- return jsonify({
- ‘success’: False,
- ‘message’: f’ 直播间 {room_id} 当前未在录制 ’
- })
- # 停止录制
- multi_poller._stop_recording(room_id)
- # 简化版:不记录手动停止录制
- return jsonify({
- ‘success’: True,
- ‘message’: f’ 已停止直播间 {room_id} 的录制 ’
- })
- @app.route(‘/api/multi-poll/pause’, methods=[‘POST’])
- @handle_exceptions
- def pause_polling_room():
- “”” 暂停指定直播间的轮询 ”””
- data = request.get_json()
- room_id = data.get(‘room_id’)
- if not room_id:
- return jsonify({
- ‘success’: False,
- ‘message’: ‘ 缺少房间 ID’
- })
- success = multi_poller.pause_room(room_id)
- if success:
- return jsonify({
- ‘success’: True,
- ‘message’: f’ 已暂停直播间 {room_id} 的轮询 ’
- })
- else:
- return jsonify({
- ‘success’: False,
- ‘message’: f’ 直播间 {room_id} 不在轮询列表中或已暂停 ’
- })
- @app.route(‘/api/multi-poll/resume’, methods=[‘POST’])
- @handle_exceptions
- def resume_polling_room():
- “”” 恢复指定直播间的轮询 ”””
- data = request.get_json()
- room_id = data.get(‘room_id’)
- if not room_id:
- return jsonify({
- ‘success’: False,
- ‘message’: ‘ 缺少房间 ID’
- })
- success = multi_poller.resume_room(room_id)
- if success:
- return jsonify({
- ‘success’: True,
- ‘message’: f’ 已恢复直播间 {room_id} 的轮询 ’
- })
- else:
- return jsonify({
- ‘success’: False,
- ‘message’: f’ 直播间 {room_id} 不在轮询列表中或未暂停 ’
- })
- if __name__ == ‘__main__’:
- # 创建录制目录
- os.makedirs(‘recordings’, exist_ok=True)
- # 监听所有接口,允许外部访问
- app.run(host=’0.0.0.0′, port=5000, debug=True)
前端: - <template>
- <div class=”multi-room-manager”>
- <div class=”header”>
- <h3> 多直播间管理 </h3>
- <div class=”header-actions”>
- <button @click=”showHistory = !showHistory” class=”history-btn”>
- {{showHistory ? ‘ 隐藏历史 ’ : ‘ 查看历史 ’}}
- </button>
- <button @click=”showAddDialog = true” class=”add-btn”> 添加直播间 </button>
- </div>
- </div>
- <!– 播放器区域 –>
- <div class=”players-section”>
- <h3> 直播播放器 </h3>
- <div class=”players-container”>
- <div
- v-for=”(player, index) in players”
- :key=”index”
- class=”player-wrapper”
- >
- <div class=”player-header”>
- <span class=”player-title”>{{player.title}}</span>
- <button @click=”closePlayer(index)” class=”close-player-btn”>×</button>
- </div>
- <div class=”player-controls”>
- <button @click=”toggleMute(index)” class=”mute-btn”>
- {{player.muted ? ‘🔇 静音 ’ : ‘🔊 取消静音 ’}}
- </button>
- <button @click=”play(index)” class=”play-btn”> 播放 </button>
- </div>
- <video :ref=”`videoPlayer${index}`” controls autoplay muted class=”inline-video-player”></video>
- <div v-if=”player.error” class=”player-error”>{{player.error}}</div>
- </div>
- <div v-if=”players.length === 0″ class=”no-players”>
- 暂无播放器,请点击直播间中的 ” 播放 ” 按钮添加播放器
- </div>
- </div>
- </div>
- <!– 批量操作栏 –>
- <div v-if=”selectedRooms.length > 0″ class=”bulk-action-bar”>
- <div class=”bulk-info”>
- 已选择 {{selectedRooms.length}} 个直播间
- </div>
- <div class=”bulk-actions”>
- <button @click=”bulkStartRecording” class=”bulk-record-btn”> 批量录制 </button>
- <button @click=”bulkStopRecording” class=”bulk-stop-btn”> 批量停止录制 </button>
- <button @click=”bulkPause” class=”bulk-pause-btn”> 批量暂停 </button>
- <button @click=”bulkResume” class=”bulk-resume-btn”> 批量恢复 </button>
- <button @click=”bulkRemove” class=”bulk-remove-btn”> 批量移除 </button>
- <button @click=”clearSelection” class=”bulk-clear-btn”> 取消选择 </button>
- </div>
- </div>
- <!– 添加直播间对话框 –>
- <div v-if=”showAddDialog” class=”dialog-overlay”>
- <div class=”dialog”>
- <h4> 添加直播间 </h4>
- <div class=”form-group”>
- <label> 直播间地址:</label>
- <input
- v-model=”newRoom.url”
- placeholder=” 输入房间号或直播链接(如:123456 或 https://live.douyin.com/123456)”
- class=”input-field”
- />
- </div>
- <div class=”form-group”>
- <label> 检查间隔(秒):</label>
- <input
- v-model.number=”newRoom.interval”
- type=”number”
- placeholder=”60″
- min=”30″
- max=”3600″
- class=”input-field”
- />
- </div>
- <div class=”form-group”>
- <label>
- <input
- v-model=”newRoom.autoRecord”
- type=”checkbox”
- />
- 开播时自动录制
- </label>
- </div>
- <div class=”dialog-actions”>
- <button @click=”addRoom” class=”confirm-btn”> 添加 </button>
- <button @click=”cancelAdd” class=”cancel-btn”> 取消 </button>
- </div>
- </div>
- </div>
- <!– 直播间列表 –>
- <div class=”room-list”>
- <div
- v-for=”(room, roomId) in sortedPollingRooms”
- :key=”roomId”
- class=”room-item”
- :class=”[getStatusClass(room.status), {‘selected’: selectedRooms.includes(roomId) }]”
- @click.ctrl.exact=”toggleRoomSelection(roomId)”
- @click.shift.exact=”selectRoomRange(roomId)”
- >
- <div class=”room-selection”>
- <input
- type=”checkbox”
- :checked=”selectedRooms.includes(roomId)”
- @click.stop=”toggleRoomSelection(roomId)”
- class=”room-checkbox”
- />
- </div>
- <div class=”room-info”>
- <div class=”room-id”> 房间: {{roomId}}
- <span v-if=”room.anchor_name && room.anchor_name !== `anchor_${roomId}`” class=”anchor-name”>
- ({{room.anchor_name}})
- </span>
- </div>
- <div class=”room-status”>
- 状态: {{getStatusText(room.status) }}
- <span v-if=”room.status === ‘live’ && (room.online_count > 0 || room.viewer_count_text)” class=”popularity”>
- 人气:{{formatPopularity(room) }}
- </span>
- <span v-if=”room.last_check” class=”last-check”>
- ({{formatTime(room.last_check) }})
- </span>
- </div>
- <div class=”room-url”>{{room.room_url}}</div>
- <div v-if=”room.stream_url” class=”stream-url”>
- 流地址: {{room.stream_url.substring(0, 50) }}…
- </div>
- </div>
- <div class=”room-actions”>
- <!– 播放按钮 –>
- <button
- v-if=”room.status === ‘live’ && room.stream_url”
- @click.stop=”playStream(room.stream_url)”
- class=”play-btn”
- >
- 播放
- </button>
- <!– 录制控制 –>
- <button
- v-if=”room.status === ‘live’ && !room.recording_session_id”
- @click.stop=”startRecording(roomId)”
- class=”record-btn”
- >
- 开始录制
- </button>
- <button
- v-if=”room.recording_session_id”
- @click.stop=”stopRecording(roomId)”
- class=”stop-record-btn”
- >
- 停止录制
- </button>
- <!– 暂停 / 恢复按钮 –>
- <button
- v-if=”room.status !== ‘paused'”
- @click.stop=”pauseRoom(roomId)”
- class=”pause-btn”
- >
- 暂停
- </button>
- <button
- v-else
- @click.stop=”resumeRoom(roomId)”
- class=”resume-btn”
- >
- 恢复
- </button>
- <!– 删除直播间 –>
- <button
- @click.stop=”removeRoom(roomId)”
- class=”remove-btn”
- >
- 移除
- </button>
- </div>
- </div>
- </div>
- <!– 统计信息 –>
- <div class=”stats”>
- <div class=”stat-item”>
- <span class=”stat-label”> 总房间数:</span>
- <span class=”stat-value”>{{totalRooms}}</span>
- </div>
- <div class=”stat-item”>
- <span class=”stat-label”> 在线房间:</span>
- <span class=”stat-value”>{{liveRooms}}</span>
- </div>
- <div class=”stat-item”>
- <span class=”stat-label”> 录制中:</span>
- <span class=”stat-value”>{{recordingRooms}}</span>
- </div>
- <div class=”stat-item”>
- <span class=”stat-label”> 已暂停:</span>
- <span class=”stat-value”>{{pausedRooms}}</span>
- </div>
- </div>
- <!– 错误信息 –>
- <div v-if=”error” class=”error-message”>
- {{error}}
- </div>
- <!– 历史记录区域 –>
- <div v-if=”showHistory” class=”history-section”>
- <div class=”history-header”>
- <h4> 轮询历史记录 </h4>
- <div class=”history-filters”>
- <button @click=”refreshHistory” class=”refresh-btn”> 刷新 </button>
- </div>
- </div>
- <div class=”history-list”>
- <div v-if=”historyLoading” class=”loading”> 加载中 …</div>
- <div v-else-if=”historyRecords.length === 0″ class=”no-history”> 暂无历史记录 </div>
- <div v-else>
- <div
- v-for=”record in historyRecords”
- :key=”record.id”
- class=”history-item”
- >
- <div class=”history-info”>
- <div class=”history-main”>
- <span class=”anchor-name”>{{record.anchor_name}}</span>
- <span class=”room-url”>{{record.room_url}}</span>
- </div>
- <div class=”history-time”>{{record.date}} {{record.time}}</div>
- </div>
- </div>
- <!– 加载更多按钮 –>
- <div v-if=”historyRecords.length >= 50″ class=”load-more”>
- <button @click=”loadMoreHistory” class=”load-more-btn”> 加载更多 </button>
- </div>
- </div>
- </div>
- </div>
- </div>
- </template>
- <script>
- import flvjs from ‘flv.js’;
- export default {
- name: ‘MultiRoomManager’,
- props: {
- },
- data() {
- return {
- pollingRooms: {},
- showAddDialog: false,
- showHistory: false,
- newRoom: {
- url: ”,
- interval: 60,
- autoRecord: false
- },
- error: ”,
- updateInterval: null,
- historyRecords: [],
- historyLoading: false,
- // 播放器列表,支持多个播放器
- players: [],
- selectedRooms: [],
- lastSelectedRoom: null,
- playerError: ”
- };
- },
- computed: {
- totalRooms() {
- return Object.keys(this.pollingRooms).length;
- },
- liveRooms() {
- return Object.values(this.pollingRooms).filter(room => room.status === ‘live’).length;
- },
- recordingRooms() {
- return Object.values(this.pollingRooms).filter(room => room.recording_session_id).length;
- },
- pausedRooms() {
- return Object.values(this.pollingRooms).filter(room => room.status === ‘paused’).length;
- },
- // 新增:排序后的直播间列表
- sortedPollingRooms() {
- // 将对象转换为数组并排序
- const roomsArray = Object.entries(this.pollingRooms);
- // 排序规则:
- // 1. 录制中的直播间在最上面
- // 2. 在线但未录制的直播间
- // 3. 暂停和直播结束的直播间在最下面
- roomsArray.sort((a, b) => {
- const [roomIdA, roomA] = a;
- const [roomIdB, roomB] = b;
- // 录制中的直播间优先级最高
- const isRecordingA = roomA.recording_session_id ? 1 : 0;
- const isRecordingB = roomB.recording_session_id ? 1 : 0;
- if (isRecordingA !== isRecordingB) {
- return isRecordingB – isRecordingA; // 录制中的在前面
- }
- // 在线状态的直播间优先级次之
- const isLiveA = roomA.status === ‘live’ ? 1 : 0;
- const isLiveB = roomB.status === ‘live’ ? 1 : 0;
- if (isLiveA !== isLiveB) {
- return isLiveB – isLiveA; // 在线的在前面
- }
- // 暂停和直播结束的直播间优先级最低
- const isPausedOrEndedA = (roomA.status === ‘paused’ || roomA.status === ‘live_no_stream’) ? 1 : 0;
- const isPausedOrEndedB = (roomB.status === ‘paused’ || roomB.status === ‘live_no_stream’) ? 1 : 0;
- if (isPausedOrEndedA !== isPausedOrEndedB) {
- return isPausedOrEndedA – isPausedOrEndedB; // 暂停和结束的在后面
- }
- // 如果优先级相同,按房间 ID 排序
- return roomIdA.localeCompare(roomIdB);
- });
- // 转换回对象格式
- const sortedRooms = {};
- roomsArray.forEach(([roomId, room]) => {
- sortedRooms[roomId] = room;
- });
- return sortedRooms;
- }
- },
- mounted() {
- this.loadStatus();
- this.loadHistory(); // 加载历史记录
- // 每 5 秒更新一次状态
- this.updateInterval = setInterval(this.loadStatus, 5000);
- },
- beforeDestroy() {
- if (this.updateInterval) {
- clearInterval(this.updateInterval);
- }
- // 销毁所有播放器
- this.players.forEach(playerObj => {
- if (playerObj.player) {
- playerObj.player.destroy();
- }
- });
- },
- methods: {
- async loadStatus() {
- try {
- const response = await fetch(‘http://127.0.0.1:5000/api/multi-poll/status’);
- if (!response.ok) {
- throw new Error(`HTTP error! status: ${response.status}`);
- }
- const data = await response.json();
- if (data.success) {
- this.pollingRooms = data.polling_rooms;
- this.error = ”;
- } else {
- this.error = data.message || ‘ 获取状态失败 ’;
- }
- } catch (error) {
- console.error(‘ 获取状态失败:’, error);
- this.error = ‘ 连接服务器失败 ’;
- }
- },
- async addRoom() {
- if (!this.newRoom.url.trim()) {
- this.error = ‘ 请输入直播间地址 ’;
- return;
- }
- try {
- const requestData = {
- room_url: this.newRoom.url.trim(),
- check_interval: this.newRoom.interval,
- auto_record: this.newRoom.autoRecord
- };
- console.log(‘ 发送添加直播间请求:’, requestData);
- const response = await fetch(‘http://127.0.0.1:5000/api/multi-poll/add’, {
- method: ‘POST’,
- headers: {‘Content-Type’: ‘application/json’},
- body: JSON.stringify(requestData)
- });
- if (!response.ok) {
- throw new Error(`HTTP error! status: ${response.status}`);
- }
- const data = await response.json();
- console.log(‘ 后端响应:’, data);
- if (data.success) {
- this.showAddDialog = false;
- this.resetNewRoom();
- this.loadStatus(); // 刷新状态
- this.error = ”;
- console.log(‘ 直播间添加成功:’, data.room_id);
- } else {
- this.error = data.message || ‘ 添加失败 ’;
- console.error(‘ 后端返回错误:’, data.message);
- }
- } catch (error) {
- console.error(‘ 添加直播间失败:’, error);
- this.error = ‘ 添加直播间失败: ‘ + error.message;
- }
- },
- async removeRoom(roomId) {
- if (!confirm(` 确定要移除直播间 ${roomId} 吗?`)) {
- return;
- }
- try {
- const response = await fetch(‘http://127.0.0.1:5000/api/multi-poll/remove’, {
- method: ‘POST’,
- headers: {‘Content-Type’: ‘application/json’},
- body: JSON.stringify({room_id: roomId})
- });
- if (!response.ok) {
- throw new Error(`HTTP error! status: ${response.status}`);
- }
- const data = await response.json();
- if (data.success) {
- // 从选中列表中移除
- const index = this.selectedRooms.indexOf(roomId);
- if (index > -1) {
- this.selectedRooms.splice(index, 1);
- }
- this.loadStatus(); // 刷新状态
- this.error = ”;
- } else {
- this.error = data.message || ‘ 移除失败 ’;
- }
- } catch (error) {
- console.error(‘ 移除直播间失败:’, error);
- this.error = ‘ 移除直播间失败: ‘ + error.message;
- }
- },
- async startRecording(roomId) {
- try {
- const response = await fetch(‘http://127.0.0.1:5000/api/multi-poll/start-record’, {
- method: ‘POST’,
- headers: {‘Content-Type’: ‘application/json’},
- body: JSON.stringify({room_id: roomId})
- });
- if (!response.ok) {
- throw new Error(`HTTP error! status: ${response.status}`);
- }
- const data = await response.json();
- if (data.success) {
- this.loadStatus(); // 刷新状态
- this.error = ”;
- } else {
- this.error = data.message || ‘ 开始录制失败 ’;
- }
- } catch (error) {
- console.error(‘ 开始录制失败:’, error);
- this.error = ‘ 开始录制失败: ‘ + error.message;
- }
- },
- async stopRecording(roomId) {
- try {
- const response = await fetch(‘http://127.0.0.1:5000/api/multi-poll/stop-record’, {
- method: ‘POST’,
- headers: {‘Content-Type’: ‘application/json’},
- body: JSON.stringify({room_id: roomId})
- });
- if (!response.ok) {
- throw new Error(`HTTP error! status: ${response.status}`);
- }
- const data = await response.json();
- if (data.success) {
- this.loadStatus(); // 刷新状态
- this.error = ”;
- } else {
- this.error = data.message || ‘ 停止录制失败 ’;
- }
- } catch (error) {
- console.error(‘ 停止录制失败:’, error);
- this.error = ‘ 停止录制失败: ‘ + error.message;
- }
- },
- // 新增:暂停直播间(停止轮询)
- async pauseRoom(roomId) {
- try {
- const response = await fetch(‘http://127.0.0.1:5000/api/multi-poll/pause’, {
- method: ‘POST’,
- headers: {‘Content-Type’: ‘application/json’},
- body: JSON.stringify({room_id: roomId})
- });
- if (!response.ok) {
- throw new Error(`HTTP error! status: ${response.status}`);
- }
- const data = await response.json();
- if (data.success) {
- this.loadStatus(); // 刷新状态
- this.error = ”;
- } else {
- this.error = data.message || ‘ 暂停失败 ’;
- }
- } catch (error) {
- console.error(‘ 暂停直播间失败:’, error);
- this.error = ‘ 暂停直播间失败: ‘ + error.message;
- }
- },
- // 新增:恢复直播间(恢复轮询)
- async resumeRoom(roomId) {
- try {
- const response = await fetch(‘http://127.0.0.1:5000/api/multi-poll/resume’, {
- method: ‘POST’,
- headers: {‘Content-Type’: ‘application/json’},
- body: JSON.stringify({room_id: roomId})
- });
- if (!response.ok) {
- throw new Error(`HTTP error! status: ${response.status}`);
- }
- const data = await response.json();
- if (data.success) {
- this.loadStatus(); // 刷新状态
- this.error = ”;
- } else {
- this.error = data.message || ‘ 恢复失败 ’;
- }
- } catch (error) {
- console.error(‘ 恢复直播间失败:’, error);
- this.error = ‘ 恢复直播间失败: ‘ + error.message;
- }
- },
- cancelAdd() {
- this.showAddDialog = false;
- this.resetNewRoom();
- },
- resetNewRoom() {
- this.newRoom = {
- url: ”,
- interval: 60,
- autoRecord: false
- };
- },
- getStatusClass(status) {
- return {
- ‘status-live’: status === ‘live’,
- ‘status-offline’: status === ‘offline’ || status === ‘live_no_stream’,
- ‘status-checking’: status === ‘checking’,
- ‘status-error’: status === ‘error’,
- ‘status-waiting’: status === ‘waiting’,
- ‘status-paused’: status === ‘paused’
- };
- },
- getStatusText(status) {
- const statusMap = {
- ‘waiting’: ‘ 等待中 ’,
- ‘checking’: ‘ 检查中 ’,
- ‘live’: ‘ 在线 ’,
- ‘offline’: ‘ 离线 ’,
- ‘error’: ‘ 错误 ’,
- ‘live_no_stream’: ‘ 直播结束 ’,
- ‘paused’: ‘ 已暂停 ’
- };
- return statusMap[status] || status;
- },
- formatTime(timeStr) {
- if (!timeStr) return ”;
- const date = new Date(timeStr);
- return date.toLocaleTimeString();
- },
- formatPopularity(room) {
- // 优先使用原始文本(如 ”32 人在线 ”)
- if (room.viewer_count_text && room.viewer_count_text.trim()) {
- // 如果原始文本包含太多信息,尝试提取数字
- if (room.viewer_count_text.length > 20) {
- // 提取数字部分
- const match = room.viewer_count_text.match(/(在线观众[\s·]*([\d,]+)| 观众[\s·]*([\d,]+)|([\d,]+)\s* 人在线)/);
- if (match) {
- const count = (match[2] || match[3] || match[4] || ‘0’).replace(‘,’, ”);
- return `${count}人 `;
- }
- } else {
- return room.viewer_count_text;
- }
- }
- // 否则格式化数字
- const count = room.online_count || 0;
- if (count >= 10000) {
- const wan = (count / 10000).toFixed(1);
- return `${wan}万人 `;
- } else if (count > 0) {
- return `${count}人 `;
- }
- return ‘0 人 ’;
- },
- async loadHistory() {
- this.historyLoading = true;
- try {
- const response = await fetch(‘http://127.0.0.1:5000/api/multi-poll/history?limit=50’);
- if (!response.ok) {
- throw new Error(`HTTP error! status: ${response.status}`);
- }
- const data = await response.json();
- if (data.success) {
- this.historyRecords = data.history;
- } else {
- console.error(‘ 获取历史记录失败:’, data.message);
- }
- } catch (error) {
- console.error(‘ 加载历史记录失败:’, error);
- } finally {
- this.historyLoading = false;
- }
- },
- async loadMoreHistory() {
- // 加载更多历史记录(简单实现,可以扩展为真正的分页)
- this.loadHistory();
- },
- refreshHistory() {
- this.loadHistory();
- },
- // 新增:播放直播流
- playStream(streamUrl) {
- // 查找对应的直播间信息
- let roomInfo = null;
- let roomTitle = ‘ 未知直播间 ’;
- // 遍历所有直播间查找匹配的流地址
- for (const [roomId, room] of Object.entries(this.pollingRooms)) {
- if (room.stream_url === streamUrl && room.status === ‘live’) {
- roomInfo = room;
- // 使用主播名作为标题,如果没有则使用房间 ID
- roomTitle = (room.anchor_name && room.anchor_name !== `anchor_${roomId}`) ? room.anchor_name : ` 房间 ${roomId}`;
- break;
- }
- }
- // 添加新的播放器到播放器列表
- const playerIndex = this.players.length;
- this.players.push({
- url: streamUrl,
- player: null,
- error: ”,
- muted: true, // 默认静音
- title: roomTitle // 添加直播间标题
- });
- this.$nextTick(() => {
- this.initPlayer(playerIndex);
- });
- },
- // 初始化 FLV 播放器
- initPlayer(playerIndex) {
- // 销毁已存在的播放器
- if (this.players[playerIndex].player) {
- this.players[playerIndex].player.destroy();
- this.players[playerIndex].player = null;
- }
- this.players[playerIndex].error = ”;
- try {
- if (flvjs.isSupported()) {
- const videoElement = this.$refs[`videoPlayer${playerIndex}`][0];
- this.players[playerIndex].player = flvjs.createPlayer({
- type: ‘flv’,
- url: this.players[playerIndex].url
- });
- this.players[playerIndex].player.attachMediaElement(videoElement);
- this.players[playerIndex].player.load();
- // 设置默认静音状态
- videoElement.muted = this.players[playerIndex].muted;
- this.players[playerIndex].player.play().catch(error => {
- console.error(‘ 播放失败:’, error);
- this.players[playerIndex].error = ‘ 播放失败: ‘ + error.message;
- });
- } else {
- this.players[playerIndex].error = ‘ 当前浏览器不支持 FLV 播放 ’;
- console.error(‘FLV.js is not supported’);
- }
- } catch (error) {
- console.error(‘ 初始化播放器失败:’, error);
- this.players[playerIndex].error = ‘ 初始化播放器失败: ‘ + error.message;
- }
- },
- // 新增:关闭播放器
- closePlayer(playerIndex) {
- // 销毁指定的播放器
- if (this.players[playerIndex].player) {
- this.players[playerIndex].player.destroy();
- this.players[playerIndex].player = null;
- }
- // 从播放器列表中移除
- this.players.splice(playerIndex, 1);
- },
- // 新增:切换静音状态
- toggleMute(playerIndex) {
- const playerObj = this.players[playerIndex];
- if (playerObj.player) {
- const videoElement = this.$refs[`videoPlayer${playerIndex}`][0];
- playerObj.muted = !playerObj.muted;
- videoElement.muted = playerObj.muted;
- }
- },
- // 新增:播放方法
- play(playerIndex) {
- const playerObj = this.players[playerIndex];
- if (playerObj.player) {
- // 取消静音并播放
- playerObj.muted = false;
- const videoElement = this.$refs[`videoPlayer${playerIndex}`][0];
- videoElement.muted = false;
- playerObj.player.play().catch(error => {
- console.error(‘ 播放失败:’, error);
- playerObj.error = ‘ 播放失败: ‘ + error.message;
- });
- }
- },
- // 新增:切换直播间选择
- toggleRoomSelection(roomId) {
- const index = this.selectedRooms.indexOf(roomId);
- if (index > -1) {
- // 如果已选中,则取消选中
- this.selectedRooms.splice(index, 1);
- } else {
- // 如果未选中,则选中
- this.selectedRooms.push(roomId);
- }
- this.lastSelectedRoom = roomId;
- },
- // 新增:选择范围内的直播间(Shift 键功能)
- selectRoomRange(roomId) {
- if (!this.lastSelectedRoom) {
- this.toggleRoomSelection(roomId);
- return;
- }
- const roomIds = Object.keys(this.pollingRooms);
- const lastIndex = roomIds.indexOf(this.lastSelectedRoom);
- const currentIndex = roomIds.indexOf(roomId);
- if (lastIndex === -1 || currentIndex === -1) {
- this.toggleRoomSelection(roomId);
- return;
- }
- // 确定范围
- const start = Math.min(lastIndex, currentIndex);
- const end = Math.max(lastIndex, currentIndex);
- // 选中范围内的所有直播间
- const newSelection = roomIds.slice(start, end + 1);
- // 合并选中项(避免重复)
- const uniqueSelection = […new Set([…this.selectedRooms, …newSelection])];
- this.selectedRooms = uniqueSelection;
- this.lastSelectedRoom = roomId;
- },
- // 新增:清除选择
- clearSelection() {
- this.selectedRooms = [];
- this.lastSelectedRoom = null;
- },
- // 新增:批量开始录制
- async bulkStartRecording() {
- if (this.selectedRooms.length === 0) {
- this.error = ‘ 请先选择直播间 ’;
- return;
- }
- let successCount = 0;
- let failCount = 0;
- for (const roomId of this.selectedRooms) {
- try {
- // 检查直播间是否在线且未在录制
- const room = this.pollingRooms[roomId];
- if (room.status === ‘live’ && !room.recording_session_id) {
- await this.startRecording(roomId);
- successCount++;
- }
- } catch (error) {
- console.error(` 批量开始录制失败 (房间 ${roomId}):`, error);
- failCount++;
- }
- }
- this.error = ` 批量开始录制完成: 成功 ${successCount} 个, 失败 ${failCount} 个 `;
- // 重新加载状态以更新界面
- await this.loadStatus();
- },
- // 新增:批量停止录制
- async bulkStopRecording() {
- if (this.selectedRooms.length === 0) {
- this.error = ‘ 请先选择直播间 ’;
- return;
- }
- let successCount = 0;
- let failCount = 0;
- for (const roomId of this.selectedRooms) {
- try {
- // 检查直播间是否正在录制
- const room = this.pollingRooms[roomId];
- if (room.recording_session_id) {
- await this.stopRecording(roomId);
- successCount++;
- }
- } catch (error) {
- console.error(` 批量停止录制失败 (房间 ${roomId}):`, error);
- failCount++;
- }
- }
- this.error = ` 批量停止录制完成: 成功 ${successCount} 个, 失败 ${failCount} 个 `;
- // 重新加载状态以更新界面
- await this.loadStatus();
- },
- // 新增:批量暂停(停止轮询)
- async bulkPause() {
- if (this.selectedRooms.length === 0) {
- this.error = ‘ 请先选择直播间 ’;
- return;
- }
- let successCount = 0;
- let failCount = 0;
- for (const roomId of this.selectedRooms) {
- try {
- // 检查直播间是否未暂停
- const room = this.pollingRooms[roomId];
- if (room.status !== ‘paused’) {
- await this.pauseRoom(roomId);
- successCount++;
- }
- } catch (error) {
- console.error(` 批量暂停失败 (房间 ${roomId}):`, error);
- failCount++;
- }
- }
- this.error = ` 批量暂停完成: 成功 ${successCount} 个, 失败 ${failCount} 个 `;
- // 重新加载状态以更新界面
- await this.loadStatus();
- },
- // 新增:批量恢复(恢复轮询)
- async bulkResume() {
- if (this.selectedRooms.length === 0) {
- this.error = ‘ 请先选择直播间 ’;
- return;
- }
- let successCount = 0;
- let failCount = 0;
- for (const roomId of this.selectedRooms) {
- try {
- // 检查直播间是否已暂停
- const room = this.pollingRooms[roomId];
- if (room.status === ‘paused’) {
- await this.resumeRoom(roomId);
- successCount++;
- }
- } catch (error) {
- console.error(` 批量恢复失败 (房间 ${roomId}):`, error);
- failCount++;
- }
- }
- this.error = ` 批量恢复完成: 成功 ${successCount} 个, 失败 ${failCount} 个 `;
- // 重新加载状态以更新界面
- await this.loadStatus();
- },
- // 新增:批量移除
- async bulkRemove() {
- if (this.selectedRooms.length === 0) {
- this.error = ‘ 请先选择直播间 ’;
- return;
- }
- if (!confirm(` 确定要移除选中的 ${this.selectedRooms.length} 个直播间吗?`)) {
- return;
- }
- let successCount = 0;
- let failCount = 0;
- // 创建选中房间的副本,因为在移除过程中会修改 selectedRooms 数组
- const roomsToRemove = […this.selectedRooms];
- for (const roomId of roomsToRemove) {
- try {
- await this.removeRoom(roomId);
- successCount++;
- } catch (error) {
- console.error(` 批量移除失败 (房间 ${roomId}):`, error);
- failCount++;
- }
- }
- // 清空选中列表
- this.selectedRooms = [];
- this.lastSelectedRoom = null;
- this.error = ` 批量移除完成: 成功 ${successCount} 个, 失败 ${failCount} 个 `;
- // 重新加载状态以更新界面
- await this.loadStatus();
- }
- }
- };
- </script>
- <style scoped>
- .multi-room-manager {
- background-color: #1e2127;
- border-radius: 8px;
- padding: 20px;
- color: white;
- }
- .header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 20px;
- border-bottom: 1px solid #61dafb;
- padding-bottom: 10px;
- }
- .header-actions {
- display: flex;
- gap: 10px;
- }
- /* 新增:批量操作栏样式 */
- .bulk-action-bar {
- display: flex;
- justify-content: space-between;
- align-items: center;
- background-color: #2d3748;
- border-radius: 6px;
- padding: 10px 15px;
- margin-bottom: 15px;
- border: 1px solid #4a5568;
- }
- .bulk-info {
- font-weight: bold;
- color: #61dafb;
- }
- .bulk-actions {
- display: flex;
- gap: 10px;
- flex-wrap: wrap;
- }
- .bulk-record-btn, .bulk-stop-btn, .bulk-pause-btn, .bulk-resume-btn, .bulk-remove-btn, .bulk-clear-btn {
- padding: 6px 12px;
- border: none;
- border-radius: 4px;
- cursor: pointer;
- font-size: 12px;
- min-width: 80px;
- }
- .bulk-record-btn {
- background-color: #4caf50;
- color: white;
- }
- .bulk-stop-btn {
- background-color: #ff9800;
- color: white;
- }
- .bulk-pause-btn {
- background-color: #ff5722;
- color: white;
- }
- .bulk-resume-btn {
- background-color: #2196f3;
- color: white;
- }
- .bulk-remove-btn {
- background-color: #f44336;
- color: white;
- }
- .bulk-clear-btn {
- background-color: #6c757d;
- color: white;
- }
- .history-btn {
- background-color: #2196f3;
- color: white;
- border: none;
- padding: 8px 16px;
- border-radius: 4px;
- cursor: pointer;
- font-weight: bold;
- }
- .history-btn:hover {
- background-color: #1976d2;
- }
- .parser-btn {
- background-color: #ff9800;
- color: white;
- border: none;
- padding: 8px 16px;
- border-radius: 4px;
- cursor: pointer;
- font-weight: bold;
- }
- .parser-btn:hover {
- background-color: #f57c00;
- }
- .add-btn {
- background-color: #61dafb;
- color: #282c34;
- border: none;
- padding: 8px 16px;
- border-radius: 4px;
- cursor: pointer;
- font-weight: bold;
- }
- .add-btn:hover {
- background-color: #4fa8c5;
- }
- .dialog-overlay {
- position: fixed;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- background-color: rgba(0, 0, 0, 0.5);
- display: flex;
- justify-content: center;
- align-items: center;
- z-index: 1000;
- }
- .dialog {
- background-color: #282c34;
- border-radius: 8px;
- padding: 20px;
- width: 400px;
- max-width: 90vw;
- }
- .form-group {
- margin-bottom: 15px;
- }
- .form-group label {
- display: block;
- margin-bottom: 5px;
- color: #61dafb;
- }
- .input-field {
- width: 100%;
- padding: 8px;
- border: 1px solid #61dafb;
- border-radius: 4px;
- background-color: #1e2127;
- color: white;
- box-sizing: border-box;
- }
- .dialog-actions {
- display: flex;
- gap: 10px;
- margin-top: 20px;
- }
- .confirm-btn {
- background-color: #4caf50;
- color: white;
- border: none;
- padding: 8px 16px;
- border-radius: 4px;
- cursor: pointer;
- flex: 1;
- }
- .cancel-btn {
- background-color: #f44336;
- color: white;
- border: none;
- padding: 8px 16px;
- border-radius: 4px;
- cursor: pointer;
- flex: 1;
- }
- .room-list {
- max-height: 400px;
- overflow-y: auto;
- }
- .room-item {
- border: 1px solid #444;
- border-radius: 6px;
- padding: 15px;
- margin-bottom: 10px;
- display: flex;
- justify-content: space-between;
- align-items: flex-start;
- cursor: pointer;
- transition: background-color 0.2s;
- }
- .room-item:hover {
- background-color: rgba(97, 218, 251, 0.05);
- }
- .room-item.selected {
- border-color: #61dafb;
- background-color: rgba(97, 218, 251, 0.15);
- }
- .status-live {
- border-color: #4caf50;
- background-color: rgba(76, 175, 80, 0.1);
- }
- .status-offline {
- border-color: #666;
- background-color: rgba(102, 102, 102, 0.1);
- }
- .status-checking {
- border-color: #ff9800;
- background-color: rgba(255, 152, 0, 0.1);
- }
- .status-error {
- border-color: #f44336;
- background-color: rgba(244, 67, 54, 0.1);
- }
- .status-waiting {
- border-color: #2196f3;
- background-color: rgba(33, 150, 243, 0.1);
- }
- .status-paused {
- border-color: #ff5722;
- background-color: rgba(255, 87, 34, 0.1);
- }
- .room-selection {
- display: flex;
- align-items: center;
- margin-right: 10px;
- }
- .room-checkbox {
- width: 18px;
- height: 18px;
- cursor: pointer;
- }
- .room-info {
- flex: 1;
- text-align: left;
- }
- .room-id {
- font-weight: bold;
- color: #61dafb;
- margin-bottom: 5px;
- }
- .anchor-name {
- color: #4caf50;
- font-weight: normal;
- font-size: 14px;
- }
- .room-status {
- font-size: 14px;
- margin-bottom: 5px;
- }
- .last-check {
- color: #888;
- font-size: 12px;
- }
- .popularity {
- color: #ff6b6b;
- font-weight: bold;
- font-size: 13px;
- margin-left: 8px;
- padding: 2px 6px;
- background-color: rgba(255, 107, 107, 0.1);
- border-radius: 3px;
- }
- .room-url {
- font-size: 12px;
- color: #aaa;
- margin-bottom: 5px;
- word-break: break-all;
- }
- .stream-url {
- font-size: 11px;
- color: #888;
- font-family: monospace;
- }
- .room-actions {
- display: flex;
- flex-direction: column;
- gap: 8px;
- }
- .play-btn, .record-btn, .stop-record-btn, .pause-btn, .resume-btn, .remove-btn {
- padding: 6px 12px;
- border: none;
- border-radius: 4px;
- cursor: pointer;
- font-size: 12px;
- min-width: 80px;
- }
- .play-btn {
- background-color: #2196f3;
- color: white;
- }
- .record-btn {
- background-color: #4caf50;
- color: white;
- }
- .stop-record-btn {
- background-color: #ff9800;
- color: white;
- }
- .pause-btn {
- background-color: #ff5722;
- color: white;
- }
- .resume-btn {
- background-color: #2196f3;
- color: white;
- }
- .remove-btn {
- background-color: #f44336;
- color: white;
- }
- .stats {
- display: flex;
- justify-content: space-around;
- margin-top: 20px;
- padding-top: 15px;
- border-top: 1px solid #444;
- }
- .stat-item {
- text-align: center;
- }
- .stat-label {
- display: block;
- font-size: 12px;
- color: #aaa;
- margin-bottom: 5px;
- }
- .stat-value {
- font-size: 18px;
- font-weight: bold;
- color: #61dafb;
- }
- .error-message {
- background-color: #f44336;
- color: white;
- padding: 10px;
- border-radius: 4px;
- margin-top: 15px;
- text-align: center;
- }
- /* 历史记录样式 */
- .history-section {
- margin-top: 20px;
- border-top: 2px solid #61dafb;
- padding-top: 20px;
- }
- .history-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 15px;
- }
- .history-header h4 {
- color: #61dafb;
- margin: 0;
- }
- .history-filters {
- display: flex;
- gap: 10px;
- align-items: center;
- }
- .refresh-btn {
- background-color: #4caf50;
- color: white;
- border: none;
- padding: 5px 10px;
- border-radius: 4px;
- cursor: pointer;
- font-size: 12px;
- }
- .refresh-btn:hover {
- background-color: #45a049;
- }
- .history-list {
- max-height: 400px;
- overflow-y: auto;
- border: 1px solid #444;
- border-radius: 4px;
- padding: 10px;
- }
- .loading, .no-history {
- text-align: center;
- color: #aaa;
- padding: 20px;
- }
- .history-item {
- padding: 10px;
- margin-bottom: 8px;
- border-radius: 4px;
- border-left: 4px solid #61dafb;
- background-color: rgba(97, 218, 251, 0.1);
- }
- .history-info {
- text-align: left;
- }
- .history-main {
- display: flex;
- align-items: center;
- gap: 8px;
- margin-bottom: 5px;
- }
- .anchor-name {
- font-weight: bold;
- color: #61dafb;
- }
- .room-url {
- color: #aaa;
- font-size: 12px;
- word-break: break-all;
- }
- .history-time {
- color: #888;
- font-size: 11px;
- }
- .load-more {
- text-align: center;
- margin-top: 15px;
- }
- .load-more-btn {
- background-color: #61dafb;
- color: #282c34;
- border: none;
- padding: 8px 16px;
- border-radius: 4px;
- cursor: pointer;
- font-weight: bold;
- }
- .load-more-btn:hover {
- background-color: #4fa8c5;
- }
- /* 新增:播放器模态框样式 */
- .player-modal {
- position: fixed;
- top: 0;
- left: 0;
- width: 100%;
- height: 100%;
- background-color: rgba(0, 0, 0, 0.8);
- display: flex;
- justify-content: center;
- align-items: center;
- z-index: 2000;
- }
- .player-content {
- background-color: #282c34;
- border-radius: 8px;
- padding: 20px;
- width: 80%;
- max-width: 800px;
- max-height: 80vh;
- }
- .player-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 15px;
- }
- .player-header h3 {
- margin: 0;
- color: #61dafb;
- }
- .close-btn {
- background-color: #f44336;
- color: white;
- border: none;
- padding: 5px 10px;
- border-radius: 4px;
- cursor: pointer;
- }
- .modal-video-player {
- width: 100%;
- height: auto;
- max-height: 60vh;
- background-color: #000;
- border-radius: 4px;
- }
- .players-section {
- margin-top: 20px;
- border-top: 2px solid #61dafb;
- padding-top: 20px;
- }
- .players-section h3 {
- color: #61dafb;
- margin-bottom: 15px;
- }
- .players-container {
- display: flex;
- flex-wrap: wrap;
- gap: 20px;
- }
- .player-wrapper {
- flex: 1;
- min-width: 300px;
- background-color: #2d3748;
- border-radius: 8px;
- padding: 15px;
- box-sizing: border-box;
- }
- .player-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 10px;
- }
- .player-title {
- font-weight: bold;
- color: #61dafb;
- }
- .close-player-btn {
- background-color: #f44336;
- color: white;
- border: none;
- width: 24px;
- height: 24px;
- border-radius: 50%;
- cursor: pointer;
- font-size: 16px;
- display: flex;
- align-items: center;
- justify-content: center;
- }
- .player-controls {
- display: flex;
- gap: 10px;
- margin-bottom: 10px;
- }
- .mute-btn, .play-btn {
- padding: 5px 10px;
- border: none;
- border-radius: 4px;
- cursor: pointer;
- font-size: 12px;
- }
- .mute-btn {
- background-color: #ff9800;
- color: white;
- }
- .play-btn {
- background-color: #4caf50;
- color: white;
- }
- .inline-video-player {
- width: 100%;
- height: 200px;
- background-color: #000;
- border-radius: 4px;
- }
- .player-error {
- color: #f44336;
- text-align: center;
- padding: 10px;
- margin-top: 10px;
- border: 1px solid #f44336;
- border-radius: 4px;
- background-color: rgba(244, 67, 54, 0.1);
- }
- .no-players {
- color: #888;
- font-style: italic;
- text-align: center;
- padding: 20px;
- width: 100%;
- }
- </style>
|