├── 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 |
--------------------------------------------------------------------------------