├── data ├── PID_push ├── PID_send ├── loop_tag ├── play_method ├── play_skip ├── PID_keep_pipe ├── filter_complex.txt └── configure.json ├── log └── log.log ├── pipe └── 说明.txt ├── video └── 说明.txt ├── py ├── utils │ ├── __init__.py │ ├── play_enum.py │ ├── file_io.py │ ├── cmd_execute.py │ └── LinkList.py ├── bilibili_live │ ├── __init__.py │ ├── aiorequest.py │ └── live.py ├── stop_pipe.py ├── keep_pipe.py ├── send_check.py ├── send.py ├── change.py ├── chat.py └── push.py ├── font └── 说明.txt ├── playlist ├── playlist_memory.txt └── playlist.txt └── README.md /data/PID_push: -------------------------------------------------------------------------------- 1 | xxx -------------------------------------------------------------------------------- /data/PID_send: -------------------------------------------------------------------------------- 1 | xxx -------------------------------------------------------------------------------- /data/loop_tag: -------------------------------------------------------------------------------- 1 | 1 -------------------------------------------------------------------------------- /data/play_method: -------------------------------------------------------------------------------- 1 | N -------------------------------------------------------------------------------- /data/play_skip: -------------------------------------------------------------------------------- 1 | N 1 -------------------------------------------------------------------------------- /log/log.log: -------------------------------------------------------------------------------- 1 | xxxxx -------------------------------------------------------------------------------- /pipe/说明.txt: -------------------------------------------------------------------------------- 1 | 存放管道文件 -------------------------------------------------------------------------------- /data/PID_keep_pipe: -------------------------------------------------------------------------------- 1 | xxx -------------------------------------------------------------------------------- /video/说明.txt: -------------------------------------------------------------------------------- 1 | 存放视频文件夹 -------------------------------------------------------------------------------- /py/utils/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /font/说明.txt: -------------------------------------------------------------------------------- 1 | 存放字体文件,例如:msyh.ttc -------------------------------------------------------------------------------- /playlist/playlist_memory.txt: -------------------------------------------------------------------------------- 1 | 0 -------------------------------------------------------------------------------- /py/bilibili_live/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /playlist/playlist.txt: -------------------------------------------------------------------------------- 1 | file '../video/xxx/xxx.mp4' -------------------------------------------------------------------------------- /py/stop_pipe.py: -------------------------------------------------------------------------------- 1 | """ 2 | stop_pipe 3 | 停止维持推流管道的开启 4 | """ 5 | import utils.cmd_execute as cmd_execute 6 | 7 | 8 | if __name__ == '__main__': 9 | cmd_execute.kills_load_file(path="../data/PID_keep_pipe") 10 | -------------------------------------------------------------------------------- /data/filter_complex.txt: -------------------------------------------------------------------------------- 1 | -filter_complex "drawtext=fontfile=../font/msyh.ttc:fontsize=w/40:fontcolor=white:x=0:y=0:bordercolor=black:borderw=4:text='正在播放\:$filename' [name];[name]drawtext=fontfile=../font/msyh.ttc:fontsize=w/40:fontcolor=white:x=0:y=w/40+10:bordercolor=black:borderw=4:text='%{pts\:gmtime\:$jump_start\:%H\\\\\:%M\\\\\:%S} / $Duration'[cache];[0:v][cache]overlay=x=0:y=0" 2 | -------------------------------------------------------------------------------- /py/utils/play_enum.py: -------------------------------------------------------------------------------- 1 | """ 2 | utils.play_enum 3 | 状态枚举 4 | """ 5 | from enum import Enum 6 | 7 | 8 | class PlayMethod(Enum): 9 | """ 10 | 播放模式。 11 | + NEXT : 顺序播放 12 | + PREV : 逆序播放 13 | + REPEAT : 重复播放 14 | """ 15 | NEXT = "N" 16 | PREV = "P" 17 | REPEAT = "R" 18 | 19 | 20 | class ListLoopState(Enum): 21 | """ 22 | 播放列表循环标识。 23 | + LOOP_KEEP : 继续循环 24 | + LOOP_BREAK : 跳出循环 25 | """ 26 | LOOP_KEEP = 1 27 | LOOP_BREAK = 0 28 | -------------------------------------------------------------------------------- /data/configure.json: -------------------------------------------------------------------------------- 1 | { 2 | "cmd_push": { 3 | "skip_start": "90", 4 | "skip_end": "110", 5 | "vcodec": "-vcodec libx264 -b:v 1500k -s 1024x576", 6 | "acodec": "-acodec aac -ar 44.1k -ab 300k", 7 | "format": "-f segment -segment_format mpegts -segment_time 8 pipe:%d.ts", 8 | "pipe_input": "../pipe/pipe_push" 9 | }, 10 | "cmd_send": { 11 | "pipe_output": "../pipe/pipe_push", 12 | "rtmp_address": "rtmp://127.0.0.1/live", 13 | "logfile": "../log/log.log" 14 | }, 15 | "location_dir": { 16 | "data": "../data", 17 | "log": "../log", 18 | "pipe": "../pipe", 19 | "playlist": "../playlist", 20 | "video": "../video", 21 | "py": "../py" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /py/keep_pipe.py: -------------------------------------------------------------------------------- 1 | """ 2 | keep_pipe 3 | 维持推流管道的开启 4 | """ 5 | import utils.cmd_execute as cmd_execute 6 | import utils.file_io as file_io 7 | 8 | 9 | if __name__ == '__main__': 10 | # 若管道已维持则先取消维持 11 | cmd_execute.run_until_complete(cmd="python3 stop_pipe.py") 12 | # 管道不存在则创建管道 13 | cmd_execute.run_until_complete(cmd="mkfifo ../pipe/pipe_push ../pipe/keep1 ../pipe/keep2") 14 | # 维持管道打开 15 | pid_keep1 = cmd_execute.run_not_wait(cmd="cat ../pipe/keep1 > ../pipe/pipe_push").pid 16 | pid_keep2 = cmd_execute.run_not_wait(cmd="cat ../pipe/keep2 < ../pipe/pipe_push").pid 17 | # 把pid保存到文件,用于取消维持管道 18 | file_io.text_write(path="../data/PID_keep_pipe", text=str(pid_keep1) + " " + str(pid_keep2)) 19 | -------------------------------------------------------------------------------- /py/send_check.py: -------------------------------------------------------------------------------- 1 | """ 2 | send_check 3 | 检查send进程运行状态,卡住则杀死重开 4 | """ 5 | import utils.file_io as file_io 6 | import utils.cmd_execute as cmd_execute 7 | import time 8 | 9 | 10 | if __name__ == '__main__': 11 | while True: 12 | path_log = file_io.json_load(path="../data/configure.json")["cmd_send"]["logfile"] 13 | result = cmd_execute.run_until_complete(cmd="wc -c " + path_log)[0].strip("\n") 14 | time.sleep(3) 15 | result_later = cmd_execute.run_until_complete(cmd="wc -c " + path_log)[0].strip("\n") 16 | file_io.text_write(path=path_log, text="") 17 | if result == result_later: 18 | print(time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) + " ckeck ERROR") 19 | cmd_execute.kills_load_file(path="../data/PID_send") 20 | else: 21 | print(time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) + " ckeck OK") 22 | -------------------------------------------------------------------------------- /py/utils/file_io.py: -------------------------------------------------------------------------------- 1 | """ 2 | utils.file_io 3 | 文件读写的简单封装 4 | """ 5 | import json 6 | from typing import List 7 | 8 | 9 | def text_write(path: str, text: str) -> None: 10 | """ 11 | 清空文件内容后写入文本 12 | :param path: 文件路径 13 | :param text: 写入文本内容 14 | :return: None 15 | """ 16 | with open(path, mode='w', encoding='utf-8') as f: 17 | f.write(text) 18 | f.flush() 19 | 20 | 21 | def text_read(path: str) -> List[str]: 22 | """ 23 | 以文本形式读取文件内容 24 | :param path: 文件路径 25 | :return: list 26 | """ 27 | with open(path, mode='r', encoding='utf-8') as f: 28 | lines = f.readlines() 29 | return lines 30 | 31 | 32 | def json_load(path: str) -> dict: 33 | """ 34 | 读取文件,JSON对象转换为字典类型 35 | :param path: 文件路径 36 | :return: dict 37 | """ 38 | with open(path, mode='r', encoding='utf-8') as f: 39 | return json.load(f) 40 | -------------------------------------------------------------------------------- /py/send.py: -------------------------------------------------------------------------------- 1 | """ 2 | send 3 | 读取管道中的数据,推送至rtmp服务器 4 | """ 5 | import utils.file_io as file_io 6 | import utils.cmd_execute as cmd_execute 7 | import time 8 | from atexit import register 9 | 10 | 11 | if __name__ == '__main__': 12 | @register 13 | def __clean_cmd(): 14 | cmd_execute.kills_load_file(path="../data/PID_send") 15 | print("--------------------run __clean_cmd--------------------") 16 | 17 | 18 | while True: 19 | # 获取ffmpeg运行参数 20 | configure_send = file_io.json_load(path="../data/configure.json")["cmd_send"] 21 | arg_pipe_output = configure_send["pipe_output"] 22 | arg_rtmp_address = configure_send["rtmp_address"] 23 | arg_logfile = configure_send["logfile"] 24 | # 组合命令 25 | cmd_send = "ffmpeg -hide_banner -re -i " + arg_pipe_output \ 26 | + " -vcodec copy -acodec copy -f flv " + "\"" + arg_rtmp_address + "\""\ 27 | + " 2>&1 | tee -a " + arg_logfile 28 | # 运行命令,返回popen对象 29 | process_send = cmd_execute.run_not_wait(cmd=cmd_send) 30 | # 把send_pid保存到文件 31 | file_io.text_write(path="../data/PID_send", text=str(process_send.pid)) 32 | # 等待进程执行完毕 33 | cmd_execute.wait(popen=process_send) 34 | # 以免命令错误时循环过于频繁消耗资源 35 | time.sleep(1) 36 | -------------------------------------------------------------------------------- /py/bilibili_live/aiorequest.py: -------------------------------------------------------------------------------- 1 | """ 2 | bilibili_live.aiorequest 3 | 网络连接 4 | """ 5 | import aiohttp 6 | import asyncio 7 | from atexit import register 8 | 9 | __session: aiohttp.ClientSession = None 10 | 11 | 12 | @register 13 | def __clean_session(): 14 | """ 15 | 程序退出清理session。 16 | """ 17 | if __session is None or __session.closed: 18 | return 19 | asyncio.get_event_loop().run_until_complete(__session.close()) 20 | 21 | 22 | def get_session(): 23 | global __session 24 | if __session is None: 25 | __session = aiohttp.ClientSession() 26 | return __session 27 | 28 | 29 | def set_session(session: aiohttp.ClientSession): 30 | global __session 31 | __session = session 32 | return __session 33 | 34 | 35 | async def request(method: str = None, url: str = None, data=None, headers=None, cookies=None): 36 | async with get_session().request(method=method, url=url, data=data, headers=headers, cookies=cookies) as response: 37 | try: 38 | # 检查状态码 39 | response.raise_for_status() 40 | except: 41 | raise 42 | if response.content_length == 0: 43 | return None 44 | if response.content_type.lower().find("application/json") == -1: 45 | raise Exception("content_type is not application/json") 46 | 47 | return await response.json() 48 | -------------------------------------------------------------------------------- /py/change.py: -------------------------------------------------------------------------------- 1 | """ 2 | change 3 | 识别命令后执行切换操作 4 | """ 5 | import sys 6 | import re 7 | import utils.file_io as file_io 8 | import utils.cmd_execute as cmd_execute 9 | import utils.play_enum as play_enum 10 | 11 | 12 | if __name__ == '__main__': 13 | # 只处理sys.argv[1] 14 | if len(sys.argv) == 2: 15 | arg = sys.argv[1] 16 | 17 | if "|" in arg or "&" in arg: 18 | sys.exit(0) 19 | 20 | # 换碟:本集结束播放 21 | if re.match(r'^@ *换碟 *$', arg) is not None: 22 | cmd_execute.kills_load_file(path="../data/PID_push") 23 | # 刷新 24 | if re.match(r'^@ *刷新 *$', arg) is not None: 25 | cmd_execute.kills_load_file(path="../data/PID_send") 26 | # 马上重播本集 27 | if re.match(r'^@ *[Rr] *$', arg) is not None: 28 | file_io.text_write(path="../data/play_skip", text="R 1") 29 | cmd_execute.kills_load_file(path="../data/PID_push") 30 | # 选集播放 31 | if re.match(r'^@ *([1-9]\d*) *$', arg) is not None: 32 | memory = str(int(re.match(r'^@ *([1-9]\d*) *$', arg).group(1)) - 1) 33 | file_io.text_write(path="../data/loop_tag", text=str(play_enum.ListLoopState.LOOP_BREAK.value)) 34 | file_io.text_write(path="../playlist/playlist_memory.txt", text=memory) 35 | cmd_execute.kills_load_file(path="../data/PID_push") 36 | 37 | # 匹配跳集指令 38 | match_result = re.match(r'^@ *([NnPp]) *([1-9]\d*) *$', arg) 39 | if match_result is not None: 40 | file_io.text_write(path="../data/play_skip", 41 | text=match_result.group(1).upper() + " " + match_result.group(2)) 42 | cmd_execute.kills_load_file(path="../data/PID_push") 43 | else: 44 | # 匹配切换模式指令 45 | match_result = re.match(r'^@@ *([NnPpRr]) *$', arg) 46 | if match_result is not None: 47 | file_io.text_write(path="../data/play_method", text=match_result.group(1).upper()) 48 | else: 49 | # 匹配换剧指令 50 | match_result = re.match(r'^@# *换剧 *([^ ]*) *$', arg) 51 | if match_result is not None: 52 | try: 53 | lines = file_io.text_read(path="../playlist/" + match_result.group(1)) 54 | file_io.text_write(path="../playlist/playlist.txt", text="".join(lines)) 55 | file_io.text_write(path="../data/loop_tag", text=str(play_enum.ListLoopState.LOOP_BREAK.value)) 56 | file_io.text_write(path="../playlist/playlist_memory.txt", text="") 57 | cmd_execute.kills_load_file(path="../data/PID_push") 58 | except FileNotFoundError as exc: 59 | print(exc) 60 | -------------------------------------------------------------------------------- /py/utils/cmd_execute.py: -------------------------------------------------------------------------------- 1 | """ 2 | utils.cmd_execute 3 | 执行shell命令以及杀死进程的操作 4 | """ 5 | import subprocess 6 | import os 7 | import psutil 8 | from . import file_io as file_io 9 | from typing import Tuple 10 | 11 | 12 | def run_until_complete(cmd: str, timeout: int = None) -> Tuple[str, str]: 13 | """ 14 | 执行命令,等待命令执行完毕并返回结果,结果为(stdout_data, stderr_data)元组 15 | :param cmd: 要执行的命令 16 | :param timeout: 超时时间 17 | :return: Tuple[str, str] 18 | """ 19 | proc = subprocess.Popen(cmd, 20 | shell=True, 21 | stdout=subprocess.PIPE, 22 | stderr=subprocess.PIPE, 23 | encoding="utf-8") 24 | try: 25 | outs, errs = proc.communicate(timeout=timeout) 26 | return outs, errs 27 | except subprocess.TimeoutExpired: 28 | proc.kill() 29 | outs, errs = proc.communicate() 30 | return outs, errs 31 | 32 | 33 | def run_not_wait(cmd: str) -> subprocess.Popen: 34 | """ 35 | 执行命令,返回Popen对象(需要注意这里Popen对象的pid是启动进程的pid,杀死进程可能还需要把子进程杀死) 36 | :param cmd: 要执行的命令 37 | :return: subprocess.Popen 38 | """ 39 | proc = subprocess.Popen(cmd, shell=True) 40 | return proc 41 | 42 | 43 | def wait_id(pid: int) -> None: 44 | """ 45 | 等待pid进程执行完毕 46 | :param pid: 进程pid 47 | :return: None 48 | """ 49 | try: 50 | os.waitid(os.P_PID, pid, os.WEXITED) 51 | except Exception as exc: 52 | print(exc) 53 | 54 | 55 | def wait(popen: subprocess.Popen) -> None: 56 | """ 57 | 等待popen对象生成的进程执行完毕 58 | :param popen: subprocess.Popen 59 | :return: None 60 | """ 61 | try: 62 | popen.wait() 63 | except Exception as exc: 64 | print(exc) 65 | 66 | 67 | def kills(pid: int) -> None: 68 | """ 69 | 杀死pid进程的子进程(不含该pid进程) 70 | :param pid: 进程pid 71 | :return: None 72 | """ 73 | try: 74 | proc = psutil.Process(pid) 75 | while len(proc.children()) > 0: 76 | proc.children()[0].kill() 77 | print("killed....") 78 | except Exception as exc: 79 | print(exc) 80 | 81 | 82 | def kills_old_method(pid: int) -> None: 83 | """ 84 | 杀死pid进程及其子进程 85 | :param pid: 进程pid 86 | :return: None 87 | """ 88 | cmd = "ps -ef | awk '{print $2\" \"$3}'| grep " \ 89 | + str(pid) + " | awk '{print $1}'" 90 | proc = subprocess.Popen(cmd, 91 | shell=True, 92 | stdout=subprocess.PIPE, 93 | stderr=subprocess.PIPE, 94 | encoding="utf-8") 95 | str_pid = proc.communicate()[0].replace("\n", " ") 96 | subprocess.Popen("kill -9 " + str_pid, shell=True) 97 | 98 | 99 | def kills_load_file(path: str) -> None: 100 | """ 101 | 读取路径为path的文件,提取pid并杀死pid进程的子进程 102 | :param path: 文件路径 103 | :return: None 104 | """ 105 | try: 106 | read = file_io.text_read(path=path) 107 | for line in read: 108 | for num_str in line.split(): 109 | if num_str.isdigit(): 110 | kills(pid=int(num_str)) 111 | except FileNotFoundError as exc: 112 | print(exc) 113 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

Video_Live_Stream

2 | 一个视频推流小工具 3 | 4 | ---------- 5 | 6 |

简介

7 | Video_Live_Stream是在Linux系统下基于python调用ffmpeg实现的简易推流工具,基本功能如下: 8 | 9 | * 读取播放列表,按列表顺序循环推流视频至rtmp服务器。 10 | * 添加了`bilibili直播间弹幕模块`,可接收及发送弹幕。 11 | * 可通过指令修改`视频播放模式`,结合弹幕模块可以直播间操作播放模式。 12 | 13 |

文件结构

14 | Video_Live_Stream 15 | 16 | * data 17 | * configure.json 18 | * 配置文件,存放推流参数 19 | * filter_complex.txt 20 | * 存放滤镜参数 21 | * loop_tag 22 | * 存放循环标识 23 | * PID_keep_pipe 24 | * 存放keep_pipe的pid 25 | * PID_push 26 | * 存放push进程中ffmpeg进程的pid 27 | * PID_send 28 | * 存放send进程中ffmpeg进程的pid 29 | * play_method 30 | * 存放播放模式 31 | * play_skip 32 | * 存放指令 33 | 34 | * font 35 | * msyh.ttc 36 | * 字体文件(修改后需要在data/filter_complex.txt中同步修改) 37 | * log 38 | * log.log 39 | * send进程输出日志,结合py/send_check.py使用 40 | * pipe 41 | * keep1 42 | * 管道文件,用于维持pipe/pipe_push开启 43 | * keep2 44 | * 管道文件,用于维持pipe/pipe_push开启 45 | * pipe_push 46 | * 管道文件,用于推流 47 | * playlist 48 | * playlist.txt 49 | * 当前播放列表 50 | * playlist_memory.txt 51 | * 用于保存播放进度 52 | * ... 53 | * 需自行创建的播放列表 54 | * ... 55 | * py 56 | * bilibili_live 57 | * aiorequest.py 58 | * 网络请求模块 59 | * live.py 60 | * 直播间连接模块 61 | * utils 62 | * cmd_execute.py 63 | * 命令执行模块 64 | * file_io.py 65 | * 文件读写模块 66 | * LinkList.py 67 | * 链表模块 68 | * play_enum.py 69 | * 自定义枚举模块 70 | * change.py 71 | * 发送指令 72 | * chat.py 73 | * 直播间连接,弹幕接收与发送 74 | * keep_pipe.py 75 | * 维持管道 76 | * stop_pipe.py 77 | * 取消维持管道 78 | * push.py 79 | * 视频推流至管道 80 | * send.py 81 | * 管道数据推流至rtmp服务器 82 | * send_check.py 83 | * 检查send进程运作 84 | * video 85 | * xxx文件夹 86 | * 01.mp4 87 | * 视频名称 88 | * 02.mp4 89 | * ... 90 | * ... 91 | * ... 92 | * ... 93 | 94 | ---------- 95 | 96 |

准备工作

97 | 首先把压缩包下载到本地然后解压,做好以下准备。 98 | 99 | 1、修改 data/configure.json中的参数。 100 | 101 | * cmd_push存放着push.py需要的ffmpeg命令参数,请按需修改(pipe_input一般不改)。 102 | * cmd_send存放着send.py需要的ffmpeg命令参数,一般只需要修改rtmp_address。 103 | * location_dir暂时没有使用,可以不管。 104 | 105 | 2、修改data/filter_complex.txt。 106 | 107 | * 文件中保存的使用到的滤镜配置,若不需要或者设置了-vcodec copy,则直接清空文件内容或者删除文件。 108 | 109 | 3、准备好font文件夹中的字体文件。 110 | 111 | * data/filter_complex.txt里直接指向使用font/msyh.ttc,若没有该文件或需要使用其它字体文件,请修改。 112 | 113 | 4、准备好视频文件及播放列表。 114 | 115 | * 在video文件夹里新建文件夹(例如:dir_1),在新建的文件夹中存放视频文件(需要后缀为.mp4,需要其它后缀的可在push.py中修改)。 116 | * 在playlist文件夹中新建播放列表,指向刚刚存放的视频文件,新建的播放列表名称不含后缀(例如:playlist_1,而不是playlist_1.txt)。 117 | * 播放列表格式请参考ffmpeg的播放列表格式(file '../video/文件夹/视频文件'),例如 118 | * file '../video/dir_1/01.mp4' 119 | * file '../video/dir_1/02.mp4' 120 | 121 | 5、python一般系统都内置了,没有的话请自行安装(还没进行版本测试,建议安装python3.8以上的版本),然后需要提前安装几个python模块,打开终端后执行。 122 | ```shell 123 | pip3 install psutil brotli aiohttp 124 | ``` 125 | 126 | 6、还可能需要使用到shell中的screen命令(建议先学习一下screen的用法)。 127 | ```shell 128 | sudo apt install screen 129 | ``` 130 | 131 | 7、最后不要忘了修改py/chat.py中的参数 132 | 133 | * 修改roomid为你的直播间id 134 | * 修改Cookies中的sessdata、buvid3、bili_jct 135 | ---------- 136 | 137 |

使用方法

138 | 1、把待播放的视频列表内容复制到playlist/playlist.txt中 139 | 140 | 141 | 2、进入py目录。 142 | 143 | 在当前解压目录执行 144 | ```shell 145 | cd ./Video_Live_Stream/py 146 | ``` 147 | 148 | 3、执行以下命令。 149 | 150 | 首先执行 151 | ```shell 152 | python3 keep_pipe.py 153 | ``` 154 | 155 | 然后执行 156 | ```shell 157 | screen -S live 158 | ``` 159 | 进入窗口后,执行 160 | ```shell 161 | python3 push.py 162 | 163 | 键盘按Ctrl+a+c 164 | 165 | python3 send.py 166 | 167 | 键盘按Ctrl+a+c 168 | 169 | python3 send_check.py 170 | 171 | 键盘按Ctrl+a+c 172 | 173 | python3 chat.py 174 | 175 | 键盘按Ctrl+a+d 176 | ``` 177 | 178 | 179 | 4、停止命令。 180 | 181 | 进入对应的窗口使用Ctrl+c停止命令,最后执行 182 | ```shell 183 | python3 stop_pipe.py 184 | ``` 185 | 即可 186 | 187 | ---------- 188 | 189 |

指令说明

190 | 可在b站直播间发送弹幕调整播放模式。 191 | 192 | * 换碟:本集结束播放 193 | * 输入:@换碟 194 | * 刷新:重新打开send中的ffmpeg进程 195 | * 输入:@刷新 196 | * 马上重播本集(大小写均可) 197 | * 输入:@R 198 | * 选集播放 199 | * 输入:@集数,例如@12 200 | * 跳集(大小写均可) 201 | * 输入:@N数量 跳到下n集(以顺序方式,不受播放模式影响) 202 | * 输入:@P数量 跳到上n集(以顺序方式,不受播放模式影响) 203 | * 修改顺序模式(大小写均可) 204 | * 输入:@@N 顺序播放 205 | * 输入:@@P 逆序播放 206 | * 输入:@@R 重复单集播放 207 | * 换剧 208 | * 输入:@#换剧+播放列表名称 ,例如@#换剧playlist_1 209 | ---------- 210 | 211 |

问题反馈

212 | 这个工具主要是写来自用的,目前我的b站直播间在使用,有什么问题交流的话可以在b站私信我。 213 | -------------------------------------------------------------------------------- /py/chat.py: -------------------------------------------------------------------------------- 1 | import time 2 | import asyncio 3 | import queue 4 | import copy 5 | from bilibili_live import live 6 | from utils import cmd_execute 7 | 8 | 9 | # 直播间房间id 10 | roomid = 直播间号 11 | # RoomCollection类,用于连接直播间 12 | room = live.RoomCollection(roomid=roomid) 13 | # RoomOperation类,用于发送弹幕 14 | room_operation = live.RoomOperation(roomid=roomid) 15 | # 弹幕类,用于设置弹幕信息 16 | bulletchat = live.BulletChat(msg="") 17 | # Cookies类,身份认证 18 | cookies = live.Cookies(sessdata="", 19 | buvid3="", 20 | bili_jct="") 21 | # 队列,存在待发送的弹幕信息 22 | queue_chat = queue.Queue() 23 | # 字典,存放收到的礼物信息,传输到队列后数据消失 24 | dict_gift = {} 25 | # 用于标记dict_gift是否有新添加的信息 26 | change_tag = False 27 | 28 | 29 | # 把礼物信息存放到dict_gift中 30 | async def add_gift_to_dict(user_name: str, gift_name: str, gift_num: str, gift: dict): 31 | if user_name not in gift: 32 | gift.setdefault(user_name, {}) 33 | num = gift[user_name].setdefault(gift_name, 0) 34 | gift[user_name][gift_name] = int(gift_num) + num 35 | global change_tag 36 | change_tag = True 37 | 38 | 39 | # 把dict_gift中的礼物信息提取并放到queue_chat中,清空dict_gift 40 | async def add_dict_to_queue(gift: dict, queue_chat: queue.Queue): 41 | while True: 42 | if len(gift) > 0: 43 | global change_tag 44 | change_tag = False 45 | await asyncio.sleep(2) 46 | # tag为True,说明有新的礼物存放到dict_gift,继续等待 47 | if change_tag: 48 | continue 49 | dict_copy = copy.deepcopy(gift) 50 | gift.clear() 51 | # 提取礼物信息放入队列 52 | for name in dict_copy: 53 | for gift_name in dict_copy[name]: 54 | count = dict_copy[name][gift_name] 55 | queue_chat.put('[自动回复]感谢 ' + name + ' 赠送了' + str(count) + '个' + gift_name) 56 | dict_copy.clear() 57 | 58 | await asyncio.sleep(5) 59 | 60 | 61 | # 从queue_chat队列提取信息后,发送到直播间 62 | async def send_msg_to_room(queue_chat: queue.Queue, bulletchat: live.BulletChat, cookies: live.Cookies): 63 | while True: 64 | while not queue_chat.empty(): 65 | bulletchat.msg = queue_chat.get() 66 | try: 67 | await room_operation.send_bulletchat(bulletchat=bulletchat, cookies=cookies) 68 | except Exception as exc: 69 | print(exc) 70 | await asyncio.sleep(7) 71 | else: 72 | await asyncio.sleep(5) 73 | 74 | 75 | @room.on("DANMU_MSG") 76 | async def on_danmu(msg): 77 | uname = msg['info'][2][1] 78 | text = msg['info'][1] 79 | localtime = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(msg['info'][9]['ts'])) 80 | print('[danmu]' + 'time:' + localtime + ' ' + uname + ':' + text) 81 | if text[:1] == "@": 82 | cmd_execute.run_until_complete(cmd="python3 change.py " + "\"" + text + "\"") 83 | 84 | 85 | @room.on("SEND_GIFT") 86 | async def on_gift(msg): 87 | uname = msg['data']['uname'] 88 | gift_num = str(msg['data']['num']) 89 | act = msg['data']['action'] 90 | gift_name = msg['data']['giftName'] 91 | localtime = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(msg['data']['timestamp'])) 92 | print('[gift]' + 'time:' + localtime + ' ' + uname + ' ' + act + ' ' + gift_num + ' ' + gift_name) 93 | global dict_gift 94 | # 使用字典存放礼物信息 95 | await add_gift_to_dict(user_name=uname, gift_name=gift_name, gift_num=gift_num, gift=dict_gift) 96 | 97 | 98 | @room.on("COMBO_SEND") 99 | async def on_gifts(msg): 100 | uname = msg['data']['uname'] 101 | gift_num = str(msg['data']['combo_num']) 102 | act = msg['data']['action'] 103 | gift_name = msg['data']['gift_name'] 104 | print('[combo_gift]' + uname + ' ' + act + ' ' + gift_num + ' ' + gift_name) 105 | 106 | 107 | @room.on("INTERACT_WORD") 108 | async def on_welcome(msg): 109 | global queue_chat 110 | uname = msg['data']['uname'] 111 | localtime = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(msg['data']['timestamp'])) 112 | if msg['data']['msg_type'] == 2: 113 | print('[people]' + 'time:' + localtime + ' ' + uname + ' ' + '关注了直播间') 114 | queue_chat.put('[自动回复]感谢 ' + uname + '关注直播间^.^') 115 | else: 116 | print('[people]' + 'time:' + localtime + ' ' + uname + ' ' + '进入直播间') 117 | 118 | 119 | @room.on("ENTRY_EFFECT") 120 | async def on_welcome_2(msg): 121 | uname = msg['data']['copy_writing'].split('<%')[1].split('%>')[0] 122 | localtime = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(msg['data']['trigger_time'] / 1000000000)) 123 | print('[people]高能榜' + 'time:' + localtime + ' ' + uname + ' ' + '进入直播间') 124 | 125 | 126 | async def main(): 127 | while True: 128 | remote = 'wss://broadcastlv.chat.bilibili.com:443/sub' 129 | task_dict_to_queue = asyncio.create_task(add_dict_to_queue(gift=dict_gift, queue_chat=queue_chat)) 130 | task_msg = asyncio.create_task(send_msg_to_room(queue_chat=queue_chat, bulletchat=bulletchat, cookies=cookies)) 131 | task_room_connect = asyncio.create_task(room.startup(uri=remote)) 132 | try: 133 | await asyncio.gather(task_dict_to_queue, task_msg, task_room_connect) 134 | except Exception as exc: 135 | print(exc) 136 | task_dict_to_queue.cancel() 137 | task_msg.cancel() 138 | task_room_connect.cancel() 139 | 140 | 141 | if __name__ == '__main__': 142 | try: 143 | asyncio.get_event_loop().run_until_complete(main()) 144 | except Exception as exc: 145 | print("Error:", exc) 146 | -------------------------------------------------------------------------------- /py/utils/LinkList.py: -------------------------------------------------------------------------------- 1 | """ 2 | utils.LinkList 3 | 链表的实现 4 | """ 5 | from typing import Optional 6 | 7 | 8 | class Node: 9 | """ 10 | 节点类 11 | """ 12 | def __init__(self, item): 13 | self.item = item 14 | self.prev = None 15 | self.next = None 16 | 17 | 18 | class DoubleCircleLinkList: 19 | """ 20 | 双向循环链表类 21 | """ 22 | def __init__(self): 23 | self.__count = 0 24 | self.__head = None 25 | 26 | def length(self) -> int: 27 | """ 28 | 获取链表长度 29 | :return: int 30 | """ 31 | return self.__count 32 | 33 | def head(self) -> Optional[Node]: 34 | """ 35 | 返回链表头指向的节点,空链表返回None 36 | :return: Node or None 37 | """ 38 | return self.__head 39 | 40 | def is_empty(self) -> bool: 41 | """ 42 | 判断是否为空链表 43 | :return: bool 44 | """ 45 | return self.__head is None 46 | 47 | def getNode(self, index: int) -> Optional[Node]: 48 | """ 49 | 根据索引获取节点 50 | 正向:表头 --> 表头前驱 反向:表头前驱 --> 表头后继 51 | 例如:index=0获取链表头部节点,index=-1获取链表尾部节点 52 | 超出索引范围则返回None 53 | :param index: int 索引 54 | :return: Node or None 55 | """ 56 | if self.is_empty(): 57 | return None 58 | if (index >= 0 and self.__count <= index) or (index < 0 and self.__count < abs(index)): 59 | return None 60 | targetNode = self.__head 61 | 62 | pos = index 63 | if index < 0: 64 | pos = self.__count + index 65 | if pos < self.__count / 2: 66 | for i in range(pos): 67 | targetNode = targetNode.next 68 | else: 69 | for i in range(0, (pos - self.__count), -1): 70 | targetNode = targetNode.prev 71 | 72 | return targetNode 73 | 74 | def node_index(self, node: Node) -> Optional[int]: 75 | """ 76 | 返回节点在链表中的索引,若节点不在链表中则返回None 77 | :param node: Node 节点 78 | :return: int or None 79 | """ 80 | if type(node) is not Node: 81 | print("not Node") 82 | return None 83 | if self.is_empty(): 84 | return None 85 | nodes = self.__head 86 | for i in range(self.__count): 87 | if id(node) == id(nodes): 88 | return i 89 | else: 90 | nodes = nodes.next 91 | return None 92 | 93 | def index(self, item) -> Optional[int]: 94 | """ 95 | 返回正序查找第一个匹配节点的值的索引,找不到则返回None 96 | :param item: object 97 | :return: int or None 98 | """ 99 | if self.is_empty(): 100 | return None 101 | targetNode = self.__head 102 | for i in range(self.__count): 103 | if targetNode.item == item: 104 | return i 105 | else: 106 | targetNode = targetNode.next 107 | return None 108 | 109 | def index_reverse(self, item) -> Optional[int]: 110 | """ 111 | 返回逆序查找第一个匹配节点的值的索引,找不到则返回None 112 | :param item: object 113 | :return: int or None 114 | """ 115 | if self.is_empty(): 116 | return None 117 | targetNode = self.__head.prev 118 | for i in range(-1, self.__count * -1 - 1, -1): 119 | if targetNode.item == item: 120 | return self.__count + i 121 | else: 122 | targetNode = targetNode.prev 123 | return None 124 | 125 | def insert(self, item, index: int) -> int: 126 | """ 127 | 在指定索引位置插入值为item的Node,index超过原来的索引则头部插入或尾部追加 128 | 返回: 插入后Node的索引值 129 | :param item: object 130 | :param index: int 131 | :return: int 132 | """ 133 | if self.is_empty(): 134 | self.__head = Node(item) 135 | self.__head.prev = self.__head 136 | self.__head.next = self.__head 137 | self.__count += 1 138 | return 0 139 | pos = index 140 | targetNode = self.__head 141 | if index > self.__count: 142 | pos = self.__count 143 | if index < 0: 144 | pos = self.__count + index 145 | if pos < 0: 146 | pos = 0 147 | 148 | if pos < self.__count: 149 | targetNode = self.getNode(pos) 150 | 151 | Node_insert = Node(item) 152 | if pos == 0: 153 | self.__head = Node_insert 154 | Node_insert.next = targetNode 155 | Node_insert.prev = targetNode.prev 156 | Node_insert.prev.next = Node_insert 157 | targetNode.prev = Node_insert 158 | 159 | self.__count += 1 160 | 161 | return pos 162 | 163 | def add(self, item) -> None: 164 | """ 165 | 往链表头部插入节点 166 | :param item: object 167 | :return: None 168 | """ 169 | self.insert(item, 0) 170 | 171 | def append(self, item) -> None: 172 | """ 173 | 往链表尾部插入节点 174 | :param item: object 175 | :return: None 176 | """ 177 | self.insert(item, self.length()) 178 | 179 | def pop(self, index: int = -1) -> Optional[dict]: 180 | """ 181 | 根据索引移除节点(默认最后一个节点),并返回移除节点的值的字典;若链表为空或超出索引范围,返回None 182 | :param index: int 183 | :return: dict or None 184 | """ 185 | if self.is_empty(): 186 | return None 187 | if (index >= 0 and self.__count <= index) or (index < 0 and self.__count < abs(index)): 188 | return None 189 | pos = index 190 | if index < 0: 191 | pos = self.__count + index 192 | 193 | targetNode = self.getNode(pos) 194 | 195 | if self.__count == 1: 196 | self.__head = None 197 | else: 198 | if pos == 0: 199 | self.__head = self.__head.next 200 | targetNode.prev.next = targetNode.next 201 | targetNode.next.prev = targetNode.prev 202 | 203 | result ={"result": targetNode.item} 204 | targetNode.item = None 205 | targetNode.prev = None 206 | targetNode.next = None 207 | 208 | self.__count -= 1 209 | 210 | return result 211 | 212 | def remove(self, item) -> Optional[int]: 213 | """ 214 | 正序查找第一个匹配值节点然后移除,返回被移除节点的原索引;找不到则返回None 215 | :param item: object 216 | :return: int or None 217 | """ 218 | pos = self.index(item) 219 | if pos is not None: 220 | self.pop(pos) 221 | 222 | return pos 223 | 224 | def clean(self) -> None: 225 | """ 226 | 清空链表 227 | :return: None 228 | """ 229 | while self.__count > 0: 230 | self.pop() 231 | -------------------------------------------------------------------------------- /py/push.py: -------------------------------------------------------------------------------- 1 | """ 2 | push 3 | 推送视频至管道,读取指令调整播放计划 4 | """ 5 | import utils.file_io as file_io 6 | import utils.LinkList as LinkList 7 | import utils.cmd_execute as cmd_execute 8 | import utils.play_enum as play_enum 9 | import re 10 | import time 11 | from atexit import register 12 | 13 | 14 | if __name__ == '__main__': 15 | @register 16 | def __clean_cmd(): 17 | cmd_execute.kills_load_file(path="../data/PID_push") 18 | print("--------------------run __clean_cmd--------------------") 19 | 20 | 21 | # 设置循环标识 22 | file_io.text_write(path="../data/loop_tag", text=str(play_enum.ListLoopState.LOOP_KEEP.value)) 23 | # 清除文件中的指令 24 | file_io.text_write(path="../data/play_skip", text="") 25 | # 设置播放模式为顺序播放 26 | file_io.text_write(path="../data/play_method", text=play_enum.PlayMethod.NEXT.value) 27 | link_list = LinkList.DoubleCircleLinkList() 28 | 29 | while True: 30 | # 清空链表 31 | link_list.clean() 32 | # 获取播放列表,存放到链表 33 | playlist = file_io.text_read(path="../playlist/playlist.txt") 34 | for line in playlist: 35 | if len(line.strip(" \n")) > 0 and len(line.split()) > 1: 36 | link_list.append(line.split()[1].strip("'\"")) 37 | 38 | # 获取并检查播放进度 39 | playlist_memory = file_io.text_read(path="../playlist/playlist_memory.txt") 40 | if len(playlist_memory) > 0: 41 | playlist_memory = playlist_memory[0].strip("\n ") 42 | if playlist_memory.isdigit(): 43 | playlist_memory = int(playlist_memory) 44 | if playlist_memory >= link_list.length(): 45 | playlist_memory = 0 46 | else: 47 | playlist_memory = 0 48 | else: 49 | playlist_memory = 0 50 | 51 | # 保存播放进度到文件 52 | file_io.text_write(path="../playlist/playlist_memory.txt", text=str(playlist_memory)) 53 | 54 | # 获取记忆进度对应的节点 55 | Node_memory = link_list.getNode(playlist_memory) 56 | # 设置循环标记 57 | file_io.text_write(path="../data/loop_tag", text=str(play_enum.ListLoopState.LOOP_KEEP.value)) 58 | 59 | while True: 60 | # 获取ffmpeg运行参数 61 | configure_push = file_io.json_load(path="../data/configure.json")["cmd_push"] 62 | arg_skip_start = configure_push["skip_start"] 63 | arg_skip_end = configure_push["skip_end"] 64 | arg_vcodec = configure_push["vcodec"] 65 | arg_acodec = configure_push["acodec"] 66 | arg_format = configure_push["format"] 67 | arg_pipe_input = configure_push["pipe_input"] 68 | # 获取视频信息,用于提取视频时长(包含单位为秒的时长以及格式为HH:MM:SS的时长) 69 | media_message = cmd_execute.run_until_complete(cmd='ffprobe -hide_banner -show_format ' 70 | + Node_memory.item + ' 2>&1')[0] 71 | # 文件存在则执行命令,不存在则跳过本次执行 72 | if media_message.count("No such file or directory") == 0: 73 | # 提取时长后,计算:结束时间 = 总时长 - 跳过片尾时长 74 | arg_end_time = str(int(re.search(r'duration=(.*?)\.', media_message).group(1)) - int(arg_skip_end)) 75 | # 获取HH:MM:SS格式时间,并转换为HH\:MM\:SS 76 | arg_Duration = re.search(r'Duration: (.*?)\.', media_message).group(1).replace(":", r"\:") 77 | # 获取并设置filter_complex参数 78 | try: 79 | arg_filter_complex = file_io.text_read(path="../data/filter_complex.txt")[0].strip("\n") \ 80 | .replace("$filename", 81 | Node_memory.item[Node_memory.item.rfind("/") + 1:Node_memory.item.rfind(".mp4")]) \ 82 | .replace("$Duration", arg_Duration) \ 83 | .replace("$jump_start", arg_skip_start) 84 | except Exception as exc: 85 | arg_filter_complex = "" 86 | # 组合命令 87 | cmd_push = "ffmpeg -hide_banner -ss " + arg_skip_start + " -to " + arg_end_time + " -i " \ 88 | + Node_memory.item + " " + arg_vcodec + " " + arg_filter_complex \ 89 | + " " + arg_acodec + " " + arg_format + " | cat - >> " + arg_pipe_input 90 | # 运行命令,返回popen对象 91 | process_push = cmd_execute.run_not_wait(cmd=cmd_push) 92 | # 把push_pid保存到文件 93 | file_io.text_write(path="../data/PID_push", text=str(process_push.pid)) 94 | # 等待进程执行完毕 95 | cmd_execute.wait(popen=process_push) 96 | else: 97 | print(media_message) 98 | 99 | # 以免命令错误时循环过于频繁消耗资源 100 | time.sleep(0.3) 101 | 102 | # 若循环指示为LOOP_BREAK,则跳出循环 103 | loop_tag = file_io.text_read(path="../data/loop_tag") 104 | if len(loop_tag) > 0 \ 105 | and loop_tag[0].strip("\n").isdigit() \ 106 | and int(loop_tag[0].strip("\n")) == play_enum.ListLoopState.LOOP_BREAK.value: 107 | file_io.text_write(path="../data/loop_tag", text=str(play_enum.ListLoopState.LOOP_KEEP.value)) 108 | # 清除文件中的指令 109 | file_io.text_write(path="../data/play_skip", text="") 110 | break 111 | 112 | # 准备获取下一个播放节点 113 | 114 | tuple_play_method = (play_enum.PlayMethod.NEXT.value, 115 | play_enum.PlayMethod.PREV.value, 116 | play_enum.PlayMethod.REPEAT.value) 117 | # 读取指令 118 | play_skip = file_io.text_read(path="../data/play_skip") 119 | # 清除文件中的指令 120 | file_io.text_write(path="../data/play_skip", text="") 121 | # 指令存在则检查格式是否正确 122 | if len(play_skip) > 0 and re.match(r'^([NPR]) +([1-9]\d*)$', play_skip[0].strip("\n")) is not None: 123 | play_method, num = re.match(r'^([NPR]) +([1-9]\d*)$', play_skip[0].strip("\n")).groups() 124 | num = int(num) 125 | # 指令不存在或格式错误则按原来的模式继续播放 126 | else: 127 | # 获取播放模式 128 | play_method = file_io.text_read(path="../data/play_method") 129 | if len(play_method) > 0 and play_method[0].strip("\n") in tuple_play_method: 130 | play_method = play_method[0].strip("\n") 131 | else: 132 | file_io.text_write(path="../data/play_method", text=play_enum.PlayMethod.NEXT.value) 133 | play_method = play_enum.PlayMethod.NEXT.value 134 | num = 1 135 | # 按对应模式处理 136 | if play_method == play_enum.PlayMethod.NEXT.value: 137 | for i in range(num): 138 | Node_memory = Node_memory.next 139 | playlist_memory += 1 140 | elif play_method == play_enum.PlayMethod.PREV.value: 141 | for i in range(num): 142 | Node_memory = Node_memory.prev 143 | playlist_memory -= 1 144 | elif play_method == play_enum.PlayMethod.REPEAT.value: 145 | pass 146 | # 重新计算好进度,写入文件 147 | playlist_memory %= link_list.length() 148 | file_io.text_write(path="../playlist/playlist_memory.txt", text=str(playlist_memory)) 149 | -------------------------------------------------------------------------------- /py/bilibili_live/live.py: -------------------------------------------------------------------------------- 1 | """ 2 | bilibili_live.live 3 | 直播间WebSocket连接与弹幕发送 4 | """ 5 | import asyncio 6 | import aiohttp 7 | import struct 8 | import json 9 | import brotli 10 | import time 11 | from typing import Coroutine 12 | from struct import Struct 13 | from . import aiorequest 14 | 15 | 16 | class Event: 17 | """ 18 | 事件类 19 | """ 20 | def __init__(self, event_name: str, data: any): 21 | self.__event_name = event_name 22 | self.__data = data 23 | 24 | def getEvent_name(self): 25 | return self.__event_name 26 | 27 | def getData(self): 28 | return self.__data 29 | 30 | 31 | class EventTarget: 32 | """ 33 | 发布-订阅模式异步事件支持类 34 | """ 35 | def __init__(self): 36 | self.__listeners = {} 37 | 38 | def addEventListener(self, event_name: str, callback: Coroutine): 39 | """ 40 | 注册事件监听器。 41 | :param event_name:事件名称。 42 | :param callback:回调函数。 43 | :return:None。 44 | """ 45 | if event_name not in self.__listeners: 46 | self.__listeners[event_name] = [] 47 | self.__listeners[event_name].append(callback) 48 | 49 | def removeEventListener(self, event_name: str, callback: Coroutine): 50 | """ 51 | 移除事件监听函数。 52 | :param event_name:事件名称。 53 | :param callback:回调函数。 54 | :return:None。 55 | """ 56 | if event_name not in self.__listeners: 57 | return 58 | if callback in self.__listeners[event_name]: 59 | self.__listeners[event_name].remove(callback) 60 | 61 | def dispatchEvent(self, event: Event): 62 | """ 63 | 分发事件 64 | :param event: 事件类。 65 | :return: None。 66 | """ 67 | event_name = event.getEvent_name() 68 | if event_name not in self.__listeners: 69 | return 70 | data = event.getData() 71 | for callback in self.__listeners[event_name]: 72 | asyncio.create_task(callback(data)) 73 | 74 | def on(self, event_name: str): 75 | """ 76 | 装饰器注册事件监听器 77 | :param event_name: 事件名称。 78 | """ 79 | def decorator(func: Coroutine): 80 | self.addEventListener(event_name, func) 81 | return func 82 | 83 | return decorator 84 | 85 | 86 | class RoomCollection(EventTarget): 87 | """ 88 | 直播间连接类 89 | """ 90 | def __init__(self, roomid: int, head_struct_str: str = '>IHHII'): 91 | super().__init__() 92 | self.__roomid = roomid 93 | self.__head_struct = Struct(head_struct_str) 94 | self.__json_certification_utf8 = json.dumps( 95 | { 96 | "uid": 0, 97 | "roomid": roomid, 98 | "protover": 3, 99 | "platform": "web", 100 | "type": 2, 101 | "key": "" 102 | }).encode() 103 | self.__timer = None 104 | self.__task = [] 105 | 106 | def __pack(self, packet_len: int, head_len: int, 107 | packet_ver: int, packet_type: int, 108 | num: int, data: bytes): 109 | """ 110 | 打包函数,按固定头部格式打包数据 111 | :return:bytes 112 | """ 113 | return self.__head_struct.pack(packet_len, head_len, 114 | packet_ver, packet_type, 115 | num) + data 116 | 117 | def __decorator_unpack(func): 118 | """ 119 | 装饰器用于处理接收的数据 120 | """ 121 | def unpack(self, packet: bytes): 122 | """ 123 | 解包函数 124 | :param packet: 二进制数据。 125 | :return: None。 126 | """ 127 | result = [] 128 | data = {} 129 | offset = 0 130 | while offset < len(packet): 131 | tup_head = self.__head_struct.unpack(packet[offset:offset + 16]) 132 | if tup_head[2] == 3: 133 | # 压缩过的数据需要解压处理 134 | box = brotli.decompress(packet[offset + 16:]) 135 | offset_dec = 0 136 | while offset_dec < len(box): 137 | tup_head_dec = self.__head_struct.unpack(box[offset_dec:offset_dec + 16]) 138 | data = { 139 | "packet_len": tup_head_dec[0], 140 | "head_len": tup_head_dec[1], 141 | "packet_ver": tup_head_dec[2], 142 | "packet_type": tup_head_dec[3], 143 | "num": tup_head_dec[4], 144 | "data": json.loads(box[offset_dec + 16:offset_dec + tup_head_dec[0]].decode()) 145 | } 146 | result.append(data) 147 | offset_dec += tup_head_dec[0] 148 | else: 149 | data = { 150 | "packet_len": tup_head[0], 151 | "head_len": tup_head[1], 152 | "packet_ver": tup_head[2], 153 | "packet_type": tup_head[3], 154 | "num": tup_head[4], 155 | "data": None 156 | } 157 | if tup_head[3] == 3: 158 | # 心跳包反馈,提取人气值 159 | data["data"] = {"view": struct.unpack('>I', packet[offset + 16:offset + 20])[0]} 160 | else: 161 | data["data"] = json.loads(packet[offset + 16:offset + tup_head[0]].decode()) 162 | result.append(data) 163 | offset += tup_head[0] 164 | 165 | func(self, result) 166 | 167 | return unpack 168 | 169 | @__decorator_unpack 170 | def __handleMessage(self, result: list): 171 | """ 172 | 数据分发处理函数 173 | :param result: 待分发的数据列表。 174 | """ 175 | for packet in result: 176 | if packet["packet_type"] == 5: 177 | if packet["data"]["cmd"].startswith("DANMU_MSG"): 178 | self.dispatchEvent(Event(event_name="DANMU_MSG", data=packet["data"])) 179 | else: 180 | self.dispatchEvent(Event(event_name=packet["data"]["cmd"], data=packet["data"])) 181 | elif packet["packet_type"] == 8: 182 | # 认证回应 183 | if packet["data"]["code"] == 0: 184 | # 认证成功 185 | self.dispatchEvent(Event(event_name="CERTIFY_SUCCESS", data=packet["data"])) 186 | elif packet["packet_type"] == 3: 187 | # 心跳包回应 188 | self.dispatchEvent(Event(event_name="VIEW", data=packet["data"])) 189 | self.__timer = 30 190 | else: 191 | pass 192 | 193 | def __getCertification(self): 194 | """ 195 | 获取用于加入直播间的bytes类型的认证数据 196 | :return:bytes 197 | """ 198 | return self.__pack(16 + len(self.__json_certification_utf8), 199 | 16, 1, 7, 1, self.__json_certification_utf8) 200 | 201 | async def __send_heartbeat(self, ws: aiohttp.client.ClientWebSocketResponse): 202 | """ 203 | 发送心跳包 204 | :param ws: ClientWebSocketResponse。 205 | :return: None。 206 | """ 207 | try: 208 | while True: 209 | if self.__timer == 0: 210 | await ws.send_bytes(self.__head_struct.pack(0, 16, 1, 2, 1)) 211 | elif self.__timer <= -30: 212 | raise Exception('timeout') 213 | await asyncio.sleep(1) 214 | self.__timer -= 1 215 | except asyncio.CancelledError: 216 | print("cancel Task-send_heartbeat") 217 | raise 218 | except: 219 | print("Task-send_heartbeat Error") 220 | raise 221 | 222 | async def __receive_data(self, ws: aiohttp.client.ClientWebSocketResponse): 223 | """ 224 | 接收二进制数据 225 | :param ws: ClientWebSocketResponse。 226 | :return: None。 227 | """ 228 | try: 229 | while True: 230 | async for msg in ws: 231 | if msg.data is not None: 232 | self.__handleMessage(msg.data) 233 | await ws.close() 234 | raise Exception("连接中断") 235 | except asyncio.CancelledError: 236 | print("cancel Task-receive_data") 237 | raise 238 | except: 239 | print("Task-receive_data Error") 240 | raise 241 | 242 | async def startup(self, uri: str, timeout: int = 3): 243 | """ 244 | 入口函数 245 | :param uri: WebSocket地址。 246 | :param timeout: 断开重连时间(单位:秒)。 247 | :return: None。 248 | """ 249 | print("-----房间 {room_id} 准备连接-----".format(room_id=self.__roomid)) 250 | while True: 251 | self.__timer = 30 252 | async with aiorequest.get_session().ws_connect(uri) as ws: 253 | print("-----房间 {room_id} 已建立连接-----".format(room_id=self.__roomid)) 254 | 255 | @self.on("CERTIFY_SUCCESS") 256 | async def on_certify_success(msg): 257 | print("认证成功") 258 | self.__task.append(asyncio.create_task(self.__send_heartbeat(ws))) 259 | 260 | await ws.send_bytes(self.__getCertification()) 261 | self.__task.append(asyncio.create_task(self.__receive_data(ws))) 262 | while len(self.__task) < 2: 263 | await asyncio.sleep(0.5) 264 | self.removeEventListener("CERTIFY_SUCCESS", on_certify_success) 265 | 266 | try: 267 | await asyncio.gather(self.__task[0], self.__task[1]) 268 | except Exception as err: 269 | print("Error:", err) 270 | self.__task[0].cancel() 271 | self.__task[1].cancel() 272 | finally: 273 | print("{time}秒后重连".format(time=str(timeout))) 274 | await asyncio.sleep(timeout) 275 | self.__task.clear() 276 | print("-----准备重新连接-----") 277 | 278 | 279 | class Cookies: 280 | """ 281 | Cookies类,用于身份认证 282 | 283 | """ 284 | def __init__(self, sessdata: str = None, buvid3: str = None, bili_jct: str = None): 285 | """ 286 | :param sessdata:cookie中的SESSDATA。 287 | :param buvid3:cookie中的buvid3。 288 | :param bili_jct:cookie中的bili_jct。 289 | """ 290 | self.sessdata = sessdata 291 | self.buvid3 = buvid3 292 | self.bili_jct = bili_jct 293 | 294 | def get_cookies(self) -> dict: 295 | """ 296 | 获取字典格式cookie 297 | :return:dict。 298 | """ 299 | return {"SESSDATA": self.sessdata, 300 | "buvid3": self.buvid3, 301 | "bili_jct": self.bili_jct} 302 | 303 | 304 | class BulletChat: 305 | def __init__(self, 306 | bubble: int = 0, 307 | msg: str = None, 308 | color: int = int("FFFFFF", 16), 309 | mode: int = 1, 310 | fontsize: int = 25): 311 | """ 312 | :param bubble:功能未知,默认0。 313 | :param msg:弹幕内容,长度需不大于30。 314 | :param color:字体颜色。 315 | :param mode:弹幕模式。 316 | :param fontsize:字体大小。 317 | :param roomid:房间号。 318 | """ 319 | self.bubble = bubble 320 | self.msg = msg 321 | self.color = color 322 | self.mode = mode 323 | self.fontsize = fontsize 324 | 325 | 326 | class RoomOperation: 327 | def __init__(self, roomid: int): 328 | self.__roomid = str(roomid) 329 | 330 | async def send_bulletchat(self, 331 | url: str = "https://api.live.bilibili.com/msg/send", 332 | bulletchat: BulletChat = None, 333 | cookies: Cookies = None) -> dict: 334 | """ 335 | :param url:发送弹幕url。 336 | :param bulletchat:BulletChat对象。 337 | :param cookies:Cookies对象。 338 | """ 339 | method = "POST" 340 | headers = {"referer": "https://live.bilibili.com/", 341 | "user-agent": "Mozilla/5.0"} 342 | dict_data = {"bubble": bulletchat.bubble, 343 | "msg": bulletchat.msg, 344 | "color": bulletchat.color, 345 | "mode": bulletchat.mode, 346 | "fontsize": bulletchat.fontsize, 347 | "rnd": str(int(time.time())), 348 | "roomid": self.__roomid, 349 | "csrf": cookies.bili_jct, 350 | "csrf_token": cookies.bili_jct} 351 | dict_cookies = {"SESSDATA": cookies.sessdata, 352 | "buvid3": cookies.buvid3, 353 | "bili_jct": cookies.bili_jct} 354 | try: 355 | response = await aiorequest.request(method=method, url=url, data=dict_data, headers=headers, 356 | cookies=dict_cookies) 357 | code = response.get("code") 358 | if code != 0: 359 | raise Exception(response) 360 | return response 361 | except Exception as e: 362 | print(e) 363 | --------------------------------------------------------------------------------