|
前端代码仅展示部分。请在下方下载完整源码。
先看效果图:
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到项目目录下后端服务
python app.py
复制代码前端服务
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>
复制代码 |
|