#!/usr/bin/env python3
# -*- coding:utf-8 -*-
"""
*--------------------------------------*
B站直播錄播姬 By: Red_lnn
僅支持單個主播,多個主播請復制多份并分開單獨啟動
運行時如要停止錄制并退出,請按鍵盤 Ctrl+C
如要修改錄制設置,請以純文本方式打開.py文件
利用ffmpeg直接抓取主播推送的流,不需要打開瀏覽器
*--------------------------------------*
"""
# import ffmpy3 # noqa
import logging
import os
import signal
import sys
import threading
import time
import traceback
from json import loads
from logging import handlers
from subprocess import PIPE, Popen, STDOUT
import requests
from regex import match
# 導入配置
from config import * # noqa
record_status = False # 錄制狀態(tài),True為錄制中
kill_times = 0 # 嘗試強制結束FFmpeg的次數(shù)
logging.addLevelName(15, 'FFmpeg') # 自定義FFmpeg的日志級別
logger = logging.getLogger('Record')
logger.setLevel(logging.DEBUG)
fms = '[%(asctime)s %(levelname)s] %(message)s'
# datefmt = "%Y-%m-%d %H:%M:%S"
datefmt = "%H:%M:%S"
default_handler = logging.StreamHandler(sys.stdout)
if debug:
default_handler.setLevel(logging.DEBUG)
elif verbose:
default_handler.setLevel(15)
else:
default_handler.setLevel(logging.INFO)
default_handler.setFormatter(logging.Formatter(fms, datefmt=datefmt))
logger.addHandler(default_handler)
if save_log:
# file_handler = logging.FileHandler("debug.log", mode='w+', encoding='utf-8')
if not os.path.exists(os.path.join('logs')):
os.mkdir(os.path.join('logs'))
file_handler = handlers.TimedRotatingFileHandler(os.path.join('logs', 'debug.log'), 'midnight', encoding='utf-8')
if debug:
default_handler.setLevel(logging.DEBUG)
else:
default_handler.setLevel(15)
file_handler.setFormatter(logging.Formatter(fms, datefmt=datefmt))
logger.addHandler(file_handler)
def get_timestamp() -> int:
"""
獲取當前時間戳
"""
return int(time.time())
def get_time() -> str:
"""
獲取格式化后的時間
"""
time_now = get_timestamp()
time_local = time.localtime(time_now)
dt = time.strftime("%Y%m%d_%H%M%S", time_local)
return dt
def record():
"""
錄制過程中要執(zhí)行的檢測與判斷
"""
global p, record_status, last_record_time, kill_times # noqa
while True:
line = p.stdout.readline().decode()
p.stdout.flush()
logger.log(15, line.rstrip())
if match('video:[0-9kmgB]* audio:[0-9kmgB]* subtitle:[0-9kmgB]*', line) or 'Exiting normally' in line:
record_status = False # 如果FFmpeg正常結束錄制則退出本循環(huán)
break
elif match('frame=[0-9]', line) or 'Opening' in line:
last_record_time = get_timestamp() # 獲取最后錄制的時間
elif 'Failed to read handshake response' in line:
time.sleep(5) # FFmpeg讀取m3u8流失敗,等個5s康康會不會恢復
continue
time_diff = get_timestamp() - last_record_time # 計算上次錄制到目前的時間差
if time_diff >= 65:
logger.error('最后一次錄制到目前已超65s,將嘗試發(fā)送終止信號')
logger.debug(f'間隔時間:{time_diff}s')
kill_times += 1
p.send_signal(signal.SIGTERM) # 若最后一次錄制到目前已超過65s,則認為FFmpeg卡死,嘗試發(fā)送終止信號
time.sleep(0.5)
if kill_times >= 3:
logger.critical('由于無法結束FFmpeg進程,將嘗試自我了結')
sys.exit(1)
if 'Immediate exit requested' in line:
logger.info('FFmpeg已被強制結束')
break
if p.poll() is not None: # 如果FFmpeg已退出但沒有被上一個判斷和本循環(huán)第一個判斷捕捉到,則當作異常退出
logger.error('ffmpeg未正常退出,請檢查日志文件!')
record_status = False
break
def main():
global p, room_id, record_status, last_record_time, kill_times # noqa
while True:
record_status = False
while True:
logger.info('------------------------------')
logger.info(f'正在檢測直播間:{room_id}')
try:
room_info = requests.get(f'https://api.live.bilibili.com/room/v1/Room/get_info?room_id={room_id}',
timeout=5)
except (requests.exceptions.ReadTimeout, requests.exceptions.Timeout, requests.exceptions.ConnectTimeout):
logger.error(f'無法連接至B站API,等待{check_time}s后重新開始檢測')
time.sleep(check_time)
continue
live_status = loads(room_info.text)['data']['live_status']
if live_status == 1:
break
elif live_status == 0:
logger.info(f'沒有開播,等待{check_time}s重新開始檢測')
time.sleep(check_time)
if not os.path.exists(os.path.join('download')):
try:
os.mkdir(os.path.join('download'))
except: # noqa
logger.error(f'無法創(chuàng)建下載文件夾 ↓\n{traceback.format_exc()}')
sys.exit(1)
if os.path.isfile(os.path.join('download')):
logger.error('存在與下載文件夾同名的文件')
sys.exit(1)
logger.info('正在直播,準備開始錄制')
m3u8_list = requests.get(
f'https://api.live.bilibili.com/xlive/web-room/v1/playUrl/playUrl?cid={room_id}platform=h5qn=10000')
m3u8_address = loads(m3u8_list.text)['data']['durl'][0]['url']
# 下面命令中的timeout單位為微秒,10000000us為10s(https://www.cnblogs.com/zhifa/p/12345376.html)
command = ['ffmpeg', '-rw_timeout', '10000000', '-timeout', '10000000', '-listen_timeout', '10000000',
'-headers',
'"Accept: */*? Accept-Encoding: gzip, deflate, br? Accept-Language: zh,zh-TW;q=0.9,en-US;q=0.8,en;'
f'q=0.7,zh-CN;q=0.6,ru;q=0.5? Origin: https://live.bilibili.com/{room_id}? '
'User-Agent: Mozilla/5.0 (Windows NT 10.0;Win64; x64) '
'AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.106 Safari/537.36?"', '-i',
m3u8_address, '-c:v', 'copy', '-c:a', 'copy', '-bsf:a', 'aac_adtstoasc',
'-f', 'segment', '-segment_time', str(segment_time), '-segment_start_number', '1',
os.path.join('download', f'[{room_id}]_{get_time()}_part%03d.{file_extensions}'), '-y']
if debug:
logger.debug('FFmpeg命令如下 ↓')
command_str = ''
for _ in command:
command_str += _
logger.debug(command_str)
p = Popen(command, stdin=PIPE, stdout=PIPE, stderr=STDOUT, shell=False)
record_status = True
start_time = last_record_time = get_timestamp()
try:
t = threading.Thread(target=record)
t.start()
while True:
if not record_status:
break
if verbose or debug:
time.sleep(20)
logger.info(f'--==>>> 已錄制 {round((get_timestamp() - start_time) / 60, 2)} 分鐘 ==--')
else:
time.sleep(60)
logger.info(f'--==>>> 已錄制 {int((get_timestamp() - start_time) / 60)} 分鐘 ==--')
if not record_status:
break
except KeyboardInterrupt:
# p.send_signal(signal.CTRL_C_EVENT)
logger.info('停止錄制,等待ffmpeg退出后本程序會自動退出')
logger.info('若長時間卡住,請再次按下ctrl+c (可能會損壞視頻文件)')
logger.info('Bye!')
sys.exit(0)
kill_times = 0
logger.info('FFmpeg已退出,重新開始檢測直播間')
# time.sleep(check_time)
if __name__ == '__main__':
logger.info('B站直播錄播姬 By: Red_lnn')
logger.info('如要停止錄制并退出,請按鍵盤 Ctrl+C')
logger.info('如要修改錄制設置,請以純文本方式打開.py文件')
logger.info('準備開始錄制...')
time.sleep(0.3)
try:
main()
except KeyboardInterrupt:
logger.info('Bye!')
sys.exit(0)