订单查询

所有订单均会带售后技术和客服联系方式

请购买后自行联系

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

免费下载

暂无下载链接,请访问原帖获取

内容说明

前端代码仅展示部分。请在下方下载完整源码。

先看效果图:

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>

复制代码