Compare commits

...

120 Commits

Author SHA1 Message Date
3d796ce447 绝对路径处理 2022-05-30 07:59:51 +08:00
a0fa46355b 支持自定义下载位置 2022-04-05 14:42:47 +08:00
41afd11036 Changes 2022-03-30 11:57:49 +08:00
58ef92792f 弹幕尽可能及时写入,下拨后及时保存 2022-03-29 06:43:38 +08:00
db6e0be776 弹幕xml文件写入逻辑微调 2022-03-27 15:17:32 +08:00
95c984573f 接口域名更新 2022-03-27 13:03:13 +08:00
5840b75b24 弹幕录制添加礼物记录功能(兼容DanmakuFactory) 2022-03-27 10:27:03 +08:00
ce701816b9 弹幕录制功能(兼容DanmakuFactory) 2022-03-26 23:15:19 +08:00
d83761afc0 没直播时,不可以执行更新弹幕功能 2022-03-26 23:14:49 +08:00
798af607bb 片段下载完毕后,开一个线程更新下直播信息 2022-02-11 09:29:58 +08:00
3096df86b2 status True时,提示成功,而不是失败 2022-02-09 00:35:00 +08:00
0505aeaace 添加取消所有上传过的和上传队列 2022-02-09 00:16:34 +08:00
551a8b1f18 上传失败后,不重复上传了,中断 2022-02-08 23:37:14 +08:00
e646085f0b 开播后由下载进程中断后,再去更新信息(避免一直在更新信息) 2022-02-07 14:39:40 +08:00
b8d76f6273 playlist提取,优化streamUrl设置 2022-02-07 10:28:58 +08:00
29d23a38d2 删除exp参数,由max控制 2022-02-07 10:28:38 +08:00
bf2b8956ef 删除1个无用参数 2022-02-07 10:18:48 +08:00
f14860703f 删除1个无用参数 2022-02-06 22:03:22 +08:00
8d98918e39 删除两个无用参数 2022-02-04 19:44:11 +08:00
9198413cd9 add .env 2022-02-02 15:23:23 +08:00
04fb6e956f 去除无用默认设置项 2022-02-02 15:21:12 +08:00
root
8ed3e71539 requirements更新 2022-02-02 15:18:27 +08:00
1feed57b4e 重新修正了B站无法上传多P的问题 2022-02-01 23:32:41 +08:00
b1f45ee90d 合并录播 2021-05-19 14:14:54 +08:00
2facc798cd 合并录播 2021-05-19 13:18:12 +08:00
1e24129ee7 又更新了 2021-05-19 13:17:16 +08:00
0723817bc8 Merge branch 'lubo'
# Conflicts:
#	Common.py
#	Demo/Xigua.proto
#	Demo/XiguaGift.proto
#	Demo/XiguaUser.proto
#	README.md
#	WebMain.py
#	WinMain.py
#	api.py
#	bilibili.py
#	liveDownloader.py
#	templates/head.html
2021-05-19 12:28:01 +08:00
d882b03e12 又更新了 2021-05-19 12:24:33 +08:00
8a810d1869 SB bootcdn 2021-04-08 11:36:51 +08:00
3e49fa59bb 避免无法更新状态的问题 2021-02-05 09:15:13 +08:00
f4d65e9595 添加 CHANGELOG 2021-02-05 01:14:59 +00:00
d36e7e2c5c 添加 LICENSE 2021-02-05 01:12:50 +00:00
c83c984509 删除v7版本了 2021-02-03 07:56:55 +00:00
821244a0c5 避免无法更新状态的问题 2021-02-01 10:55:03 +08:00
11a73f1aca 额外更新 2021-01-30 19:56:40 +08:00
734a7204f8 额外更新 2021-01-30 19:56:40 +08:00
27f4d25591 返回isLive 2021-01-30 19:56:29 +08:00
8bbb74eae9 状态只缓存3分钟 2021-01-30 19:52:07 +08:00
8fbe139ba0 日常更新 2021-01-30 10:13:17 +08:00
root
81dad07fd5 Merge branch 'lubo' of https://git.jerryyan.top/q792602257/XiguaLiveDanmakuHelper into lubo 2021-01-12 09:43:38 +08:00
root
30a91a38f9 细节修改 2021-01-12 09:43:09 +08:00
0b4068b9e9 避免输入了换行导致requests裂开 2021-01-12 09:36:51 +08:00
302b4d4596 URL格式更新 2020-12-06 16:09:44 +08:00
d64639af20 URL格式更新 2020-12-06 16:09:03 +08:00
9e1b725c0c URL参数格式更新 2020-12-06 11:14:10 +08:00
ecdcde9230 例行升级(9.1.8->9.2.6) 2020-12-05 15:57:58 +08:00
977cb9877f 下播判断 2020-12-05 15:47:30 +08:00
6b1a4a7b0a 例行升级(9.1.8->9.2.6),删除被动更新礼物,删除抽奖检测 2020-12-05 15:36:27 +08:00
2281f872bf 更新部分逻辑 2020-12-02 08:11:16 +00:00
bf5c4f760a Update README.md 2020-11-26 07:01:00 +00:00
5ee9a63a4e
Update README.md 2020-11-22 14:49:26 +08:00
be194f4b64 处理逻辑优化 2020-11-22 14:34:07 +08:00
b72437758f 例行升级(8.4.4->9.1.8) 2020-11-22 14:31:17 +08:00
0e00579e13 例行升级(8.4.4->9.1.8) 2020-11-22 14:21:26 +08:00
5cf4ab2fc1 更换登录类型 2020-07-06 12:03:47 +08:00
6f9616ccf3 Merge branch 'lubo' of ssh://git.jerryyan.top:29022/q792602257/XiguaLiveDanmakuHelper into lubo 2020-07-02 22:05:27 +08:00
7beddaf91e 成功之后立刻更新数据 2020-07-02 22:01:39 +08:00
e0736f1658 仅下载时无限制 2020-05-28 10:20:06 +08:00
9c5d14213e UPDATE README.md 2020-05-28 10:18:43 +08:00
88012002a5 修改默认值(待修复搜索接口) 2020-04-24 23:07:43 +08:00
290767dc29 隐藏消息 2020-04-24 20:41:19 +08:00
c8d8ba8435 西瓜又升级了(用户信息接口) 2020-04-24 20:23:03 +08:00
e10c1ff928 西瓜又升级了(用户信息接口) 2020-04-24 00:39:03 +08:00
28a62ae6c3 西瓜又升级了 2020-04-23 20:57:12 +08:00
09564f1748 西瓜又升级了 2020-04-23 20:55:25 +08:00
47baa8b2e4 USE SEND_FILE INSTEAD OF blablabla 2020-04-14 14:03:13 +08:00
df4c3a34b1 添加超时 2020-04-12 20:38:41 +08:00
b38431bf4c 添加超时 2020-04-12 20:38:40 +08:00
3b7943ca23 TEST7 2020-04-11 11:48:17 +08:00
632c0ff3ca Merge branch 'lubo' of ssh://git.jerryyan.cn:29022/q792602257/XiguaLiveDanmakuHelper into lubo 2020-04-09 23:53:17 +08:00
c31e3a8106 忘记了 2020-04-09 23:53:07 +08:00
1cc824d624 TEST5 2020-04-09 17:19:48 +08:00
113c8f2a53 TEST4 2020-04-09 17:14:35 +08:00
9d7939061c TEST3 2020-04-09 10:59:06 +08:00
44b026f5ae Merge branch 'lubo' of ssh://git.jerryyan.cn:29022/q792602257/XiguaLiveDanmakuHelper into lubo 2020-04-09 08:36:13 +08:00
d0a6aadb56 TEST2 2020-04-09 08:36:01 +08:00
47cccb4aa8 例行更新 2020-04-08 16:12:11 +08:00
9e9cb58838 例行更新 2020-04-08 16:11:51 +08:00
866b789a3d 例行更新 2020-04-08 15:55:58 +08:00
0260fce845 例行更新 2020-04-08 12:24:23 +08:00
3ae8176abb TEST 2020-04-08 11:09:29 +08:00
3225952c69 TEST 2020-04-06 15:48:38 +08:00
44211bcbc9 TAT 2020-04-04 16:47:22 +08:00
bd4082f690 添加特殊判断,后期避免报错 2020-04-04 16:42:21 +08:00
9b69e90021 添加投稿日期可自定义配置 2020-04-01 11:33:23 +08:00
5a72550598 修正漏洞,避免配置文件泄露 2020-03-30 18:54:39 +08:00
db961deace URL都没了还不更新? 2020-01-15 19:08:12 +08:00
9757051c89 避免过于频繁更新状态
(cherry picked from commit 7102a45382cb52cf6b0c47663f7a6ffa891235d7)
2020-01-15 13:04:30 +08:00
18c02b5156 stream timeout set small 2020-01-12 09:00:03 +08:00
cadfe922c0 Negative delay fix 2020-01-12 08:58:23 +08:00
53fd5c3f65 stop downloading with update room 2020-01-10 22:00:28 +08:00
179dc18ec8 Dict url 2020-01-10 21:34:21 +08:00
0f81dfd030 写错 2020-01-10 21:23:23 +08:00
11843661c0 写错 2020-01-10 20:06:47 +08:00
39318a4cc9 精简 2020-01-10 11:41:14 +08:00
68fecca012 更新房间时更新更新时间,测试下播触发条件 2020-01-10 09:18:32 +08:00
74120850a4 灵活下播判断 2020-01-09 09:26:04 +08:00
0f71209fe8 精简接口 2020-01-08 09:20:11 +08:00
fdd809fdc2 信息更新时间 2020-01-08 09:09:44 +08:00
02562b63dc 下载完成后不自动更新(依赖外面循环更新 2020-01-08 09:06:11 +08:00
6b0433138a 投稿逻辑 2020-01-07 09:13:09 +08:00
fbeff099d0 modify common 2020-01-06 08:01:30 +08:00
17f0f4aa4e login 2020-01-06 07:31:53 +08:00
0643fe0076 写错 2020-01-05 14:52:46 +08:00
ff90542cc0 判断 2020-01-05 14:52:22 +08:00
ab545da6cd modify gitign 2020-01-04 17:25:14 +08:00
b3fb7dca43 自动登录逻辑优化,延迟投稿逻辑测试 2020-01-04 17:22:13 +08:00
e29ef65d71 更新README 2020-01-03 09:48:55 +08:00
6a482af97c 修正 2020-01-03 09:33:42 +08:00
ce152f617e 修正 2020-01-03 09:30:43 +08:00
6d4127c90b 下拨投稿逻辑 2020-01-03 09:19:45 +08:00
c335e45852 更新时间 2020-01-01 13:41:55 +08:00
81fe49daec 命名更新 2020-01-01 13:40:46 +08:00
c4f2776239 命名更新 2020-01-01 13:40:00 +08:00
7200f28cca API更新 2020-01-01 13:38:26 +08:00
ea7620bced API更新 2020-01-01 13:33:35 +08:00
1590729b51 API 2020-01-01 13:31:36 +08:00
9d246dfcba 删除无用文件 2019-12-31 16:33:38 +08:00
d051250959 类型提示 2019-12-31 15:57:48 +08:00
3cdd12644e 录播端 2019-12-31 15:43:35 +08:00
42 changed files with 4125 additions and 15657 deletions

5
.env Normal file
View File

@ -0,0 +1,5 @@
FLASK_ENV=development
FLASK_DEBUG=False
FLASK_RUN_PORT=5000
FLASK_RUN_HOST=0.0.0.0
FLASK_APP=WebMain.py

6
.gitignore vendored
View File

@ -84,7 +84,6 @@ celerybeat-schedule
*.sage.py
# Environments
.env
.venv
env/
venv/
@ -178,4 +177,7 @@ fabric.properties
pyvenv.cfg
.venv
pip-selfcheck.json
*.mp4
*.flv
config*
.*

2
CHANGELOG Normal file
View File

@ -0,0 +1,2 @@
# 接口版本9.4.2(94214)
弹幕接口改为`/webcast/im/fetch/`发现会先连接websocket然而websocket又是魔改protobuf实在是弄不懂

478
Common.py Normal file
View File

@ -0,0 +1,478 @@
import os
import queue
from datetime import datetime, timedelta
import psutil
from api import XiGuaLiveApi
import json
import threading
from bilibili import Bilibili, VideoPart
# 默认设置
config = {
# 录像的主播ID
"l_u": "97621754276",
# 视频位置
"path": ".",
# 标题及预留时间位置
"t_t": "【永恒de草薙直播录播】直播于 {}",
# 标签
"tag": ["永恒de草薙", "三国", "三国战记", "直播录像", "录播", "怀旧", "街机"],
# 描述
"des": "西瓜直播 https://live.ixigua.com/userlive/97621754276 \n自动投递\n原主播永恒de草薙\n直播时间晚上6点多到凌晨4点左右",
# 来源, 空则为自制
"src": "",
# Log条数
"l_c": 5,
# 错误Log条数
"elc": 10,
# 每一chunk大小
"c_s": 16 * 1024,
# 每一块视频大小
"p_s": 2141000000,
# 忽略的大小
"i_s": 2048000,
"max": 75,
"dow": "echo 'clean'",
# 仅下载
"dlO": True,
# 下播延迟投稿
"dly": 30,
# 短的时间的格式
"sdf": "%Y%m%d",
"enc": "ffmpeg -i {f} -c:v copy -c:a copy -f mp4 {t} -y"
}
doCleanTime = datetime.fromtimestamp(0)
loginTime = datetime.fromtimestamp(0)
_clean_flag = None
delay = datetime.fromtimestamp(0)
b = Bilibili()
network = [{
"currentTime": datetime.now(),
"out": {
"currentByte": psutil.net_io_counters().bytes_sent,
},
"in": {
"currentByte": psutil.net_io_counters().bytes_recv,
}
}, {
"currentTime": datetime.now(),
"out": {
"currentByte": psutil.net_io_counters().bytes_sent,
},
"in": {
"currentByte": psutil.net_io_counters().bytes_recv,
}
}]
def reloadConfig():
global config
if os.path.exists('config.json'):
_config_fp = open("config.json", "r", encoding="utf8")
_config = json.load(_config_fp)
config.update(_config)
_config_fp.close()
def resetDelay():
global delay
delay = datetime.now() + timedelta(minutes=int(config['dly']))
def doDelay():
global delay
if -60 < getTimeDelta(datetime.now(), delay) < 60:
delay = datetime.fromtimestamp(0)
return True
return False
def updateNetwork():
global network
network.append({
"currentTime": datetime.now(),
"out": {
"currentByte": psutil.net_io_counters().bytes_sent,
},
"in": {
"currentByte": psutil.net_io_counters().bytes_recv,
}
})
network = network[-3:]
def getTimeDelta(a, b):
return (a - b).total_seconds()
def _doClean(_force=False):
global doCleanTime, _clean_flag
_disk = psutil.disk_usage(".")
if _disk.percent > config["max"] or _force:
_clean_flag = True
doCleanTime = datetime.now()
appendOperation("执行配置的清理命令")
os.system(config["dow"])
appendOperation("执行配置的清理命令完毕")
doCleanTime = datetime.now()
_clean_flag = False
def doClean(_force=False):
if _clean_flag:
return
p = threading.Thread(target=_doClean, args=(_force,))
p.setDaemon(True)
p.start()
def getCurrentStatus():
_disk = psutil.disk_usage(".")
_mem = psutil.virtual_memory()
_net = psutil.net_io_counters()
_delta = getTimeDelta(network[-1]["currentTime"], network[-2]["currentTime"])
if 60 > _delta > 1:
_inSpeed = (network[-1]["in"]["currentByte"] - network[-2]["in"]["currentByte"]) / _delta
_outSpeed = (network[-1]["out"]["currentByte"] - network[-2]["out"]["currentByte"]) / _delta
else:
_outSpeed = (network[-1]["in"]["currentByte"] - network[-2]["in"]["currentByte"])
_inSpeed = (network[-1]["out"]["currentByte"] - network[-2]["out"]["currentByte"])
updateNetwork()
return {
"memTotal": parseSize(_mem.total),
"memUsed": parseSize(_mem.used),
"memUsage": _mem.percent,
"diskTotal": parseSize(_disk.total),
"diskUsed": parseSize(_disk.used),
"diskUsage": _disk.percent,
"cpu": psutil.cpu_percent(),
"outSpeed": parseSize(_outSpeed),
"inSpeed": parseSize(_inSpeed),
"doCleanTime": datetime.strftime(doCleanTime, dt_format),
}
dt_format = "%Y/%m/%d %H:%M:%S"
reloadConfig()
broadcaster = ""
streamUrl = ""
forceNotDownload = False
forceNotBroadcasting = False
forceNotUpload = False
forceNotEncode = False
if config["dlO"] is True:
forceNotUpload = True
forceNotEncode = True
forceStartEncodeThread = False
forceStartUploadThread = False
uploadQueue = queue.Queue()
encodeQueue = queue.Queue()
uploadStatus = []
downloadStatus = []
encodeStatus = []
errors = []
operations = []
def appendOperation(obj):
global operations
if isinstance(obj, dict):
if "datetime" not in obj:
obj["datetime"] = datetime.strftime(datetime.now(), dt_format)
operations.append(obj)
else:
operations.append({
"datetime": datetime.strftime(datetime.now(), dt_format),
"message": str(obj)
})
operations = operations[-config["elc"]:]
def parseSize(size):
K = size / 1024.0
if K > 1000:
M = K / 1024.0
if M > 1000:
return "{:.2f}GB".format(M / 1024.0)
else:
return "{:.2f}MB".format(M)
else:
return "{:.2f}KB".format(K)
def appendUploadStatus(obj):
global uploadStatus
if isinstance(obj, dict):
if "datetime" not in obj:
obj["datetime"] = datetime.strftime(datetime.now(), dt_format)
uploadStatus.append(obj)
else:
uploadStatus.append({
"datetime": datetime.strftime(datetime.now(), dt_format),
"message": str(obj)
})
uploadStatus = uploadStatus[-config["l_c"]:]
def modifyLastUploadStatus(obj):
global uploadStatus
if isinstance(obj, dict):
if "datetime" not in obj:
obj["datetime"] = datetime.strftime(datetime.now(), dt_format)
uploadStatus[-1] = obj
else:
uploadStatus[-1]["message"] = str(obj)
uploadStatus[-1]["datetime"] = datetime.strftime(datetime.now(), dt_format)
def appendEncodeStatus(obj):
global encodeStatus
if isinstance(obj, dict):
if "datetime" not in obj:
obj["datetime"] = datetime.strftime(datetime.now(), dt_format)
encodeStatus.append(obj)
else:
encodeStatus.append({
"datetime": datetime.strftime(datetime.now(), dt_format),
"message": str(obj)
})
encodeStatus = encodeStatus[-config["l_c"]:]
def modifyLastEncodeStatus(obj):
global encodeStatus
if isinstance(obj, dict):
if "datetime" not in obj:
obj["datetime"] = datetime.strftime(datetime.now(), dt_format)
encodeStatus[-1] = obj
else:
encodeStatus[-1]["message"] = str(obj)
encodeStatus[-1]["datetime"] = datetime.strftime(datetime.now(), dt_format)
def appendDownloadStatus(obj):
global downloadStatus
if isinstance(obj, dict):
if "datetime" not in obj:
obj["datetime"] = datetime.strftime(datetime.now(), dt_format)
downloadStatus.append(obj)
else:
downloadStatus.append({
"datetime": datetime.strftime(datetime.now(), dt_format),
"message": str(obj)
})
downloadStatus = downloadStatus[-config["l_c"]:]
def modifyLastDownloadStatus(obj):
global downloadStatus
if isinstance(obj, dict):
if "datetime" not in obj:
obj["datetime"] = datetime.strftime(datetime.now(), dt_format)
downloadStatus[-1] = obj
else:
downloadStatus[-1]["message"] = str(obj)
downloadStatus[-1]["datetime"] = datetime.strftime(datetime.now(), dt_format)
def appendError(obj):
global errors
if isinstance(obj, dict):
if "datetime" not in obj:
obj["datetime"] = datetime.strftime(datetime.now(), dt_format)
errors.append(obj)
else:
errors.append({
"datetime": datetime.strftime(datetime.now(), dt_format),
"message": str(obj)
})
errors = errors[-config["elc"]:]
def loginBilibili(force=False):
if config["dlO"] is False or forceNotUpload is False:
global loginTime
global b
if getTimeDelta(datetime.now(), loginTime) < 86400 * 10 and not force:
return False
try:
b.login()
loginTime = datetime.now()
return True
except Exception as e:
appendError(e)
appendOperation("登录失败")
return False
else:
appendOperation("设置了不上传,所以不会登陆")
class downloader(XiGuaLiveApi):
__playlist = None
__danmakuFile = None
__danmakuBiasTime = None
def getDanmaku(self):
super(downloader, self).getDanmaku()
if self.__danmakuFile is not None and self.__danmakuFile.writable():
self.__danmakuFile.flush()
def onPresentEnd(self, gift):
if self.__danmakuFile is not None and self.__danmakuFile.writable():
now = datetime.now()
if self.__danmakuBiasTime is None:
return
ts = (now - self.__danmakuBiasTime).total_seconds()
_c = """<gift ts="{:.2f}" user="{}" giftname="{}" giftcount="{}"></gift>\r\n""".format(ts, gift.user.name, gift.name, gift.count)
self.__danmakuFile.write(_c.encode("UTF-8"))
def onChat(self, chat):
if self.__danmakuFile is not None and self.__danmakuFile.writable():
now = datetime.now()
if self.__danmakuBiasTime is None:
return
ts = (now - self.__danmakuBiasTime).total_seconds()
_c = """<d p="{:.2f},1,24,16777215,{:.0f},0,{},0" user="{}">{}</d>\r\n""".format(ts, now.timestamp()*1000, chat.user.ID, chat.user.name, chat.content)
self.__danmakuFile.write(_c.encode("UTF-8"))
@property
def playlist(self):
return self.__playlist
@playlist.setter
def playlist(self, value):
global streamUrl
self.__playlist = value
streamUrl = value
def _checkUsernameIsMatched(self, compare=None):
return True
def updRoomInfo(self, force=False):
global broadcaster
_prev_status = self.isLive
doClean()
if not force and self.isLive:
return _prev_status
_result = super(downloader, self).updRoomInfo(force)
if _prev_status != self.isLive and not self.isLive:
# 及时保存
self.__danmakuFile.close()
self.__danmakuFile = None
self.__danmakuBiasTime = None
resetDelay()
broadcaster = self.broadcaster
if _result:
if self.isLive:
self.updPlayList()
else:
self.playlist = False
return _result
def updPlayList(self):
if self.isLive and "stream_url" in self._rawRoomInfo:
if 'rtmp_pull_url' in self._rawRoomInfo["stream_url"]:
self.playlist = self._rawRoomInfo["stream_url"]['rtmp_pull_url']
elif 'flv_pull_url' in self._rawRoomInfo["stream_url"]:
_playlist = self._rawRoomInfo["stream_url"]["flv_pull_url"]
if type(_playlist) is dict:
for _ in _playlist.values():
self.playlist = _
break
self.playlist = self.playlist.replace("_hd5", "").replace("_sd5", "").replace("_ld5", "").replace("_md", "")
else:
self.playlist = None
def initSave(self, f):
if self.__danmakuFile is not None and not self.__danmakuFile.closed:
self.__danmakuFile.close()
self.__danmakuBiasTime = datetime.now()
self.__danmakuFile = open(f, "wb")
api = downloader(config["l_u"])
def doUpdatePlaylist(_force=False):
p = threading.Thread(target=api.updRoomInfo, args=(_force,))
p.setDaemon(True)
p.start()
def refreshDownloader():
global api
api = downloader(config["l_u"])
def uploadVideo(name):
if not os.path.exists(name):
appendError("Upload File Not Exist {}".format(name))
return
loginBilibili()
doClean()
if forceNotUpload is False:
b.preUpload(VideoPart(path=name, title=os.path.basename(name)))
else:
appendUploadStatus("设置了不上传,所以[{}]不会上传了".format(name))
if not forceNotEncode:
os.remove(name)
def publishVideo(date):
if forceNotUpload is False:
b.finishUpload(config["t_t"].format(date), 17, config["tag"], config["des"],
source=config["src"], no_reprint=0)
b.clear()
else:
appendUploadStatus("设置了不上传,所以[{}]的录播不会投了".format(date))
def encodeVideo(name):
if forceNotEncode:
appendEncodeStatus("设置了不编码,所以[{}]不会编码".format(name))
return False
if not os.path.exists(name):
appendEncodeStatus("文件[{}]不存在".format(name))
return False
if os.path.getsize(name) < 8 * 1024 * 1024:
appendEncodeStatus("Encoded File >{}< is too small, will ignore it".format(name))
return False
appendEncodeStatus("Encoding >{}< Start".format(name))
_new_name = os.path.splitext(name)[0] + ".mp4"
_code = os.system(config["enc"].format(f=name, t=_new_name))
if _code != 0:
appendError("Encode {} with Non-Zero Return.".format(name))
return False
modifyLastEncodeStatus("Encode >{}< Finished".format(name))
uploadQueue.put(_new_name)
def collectInfomation():
return {
"download": downloadStatus,
"encode": encodeStatus,
"encodeQueueSize": encodeQueue.qsize(),
"upload": uploadStatus,
"uploadQueueSize": uploadQueue.qsize(),
"error": errors,
"operation": operations,
"broadcast": {
"broadcaster": broadcaster.__str__(),
"isBroadcasting": api.isLive,
"streamUrl": streamUrl,
"updateTime": api.updateAt.strftime(dt_format),
"delayTime": delay.strftime(dt_format)
},
"config": {
"forceNotBroadcasting": forceNotBroadcasting,
"forceNotDownload": forceNotDownload,
"forceNotUpload": forceNotUpload,
"forceNotEncode": forceNotEncode,
"downloadOnly": config['dlO'],
},
}

BIN
Demo/242_.txt Normal file

Binary file not shown.

File diff suppressed because one or more lines are too long

Binary file not shown.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

1036
Demo/v926.txt Normal file

File diff suppressed because it is too large Load Diff

256
Demo/v926_fg.txt Normal file
View File

@ -0,0 +1,256 @@
1 {
1: "WebcastChatMessage"
2 {
1 {
1: "WebcastChatMessage"
2: 6902669212519992068
3: 6902629276546566925
6: 1
8 {
1: "webcast_chat_display_text"
2: "{0:user}{1:string}"
3 {
1: "#ff36c0cf"
4: 400
}
4 {
1: 11
2 {
1: "#60000000"
4: 400
}
21 {
1 {
1: 5518138898
3: "\345\247\232\345\247\232\347\220\263\347\232\2042\345\247\220\345\244\253"
4: 1
9 {
1: "https://p1-dy.bytexservice.com/img/user-avatar/deb96a5e7e07f60a613531670a570736~300x300.image"
}
21 {
1: "http://p9-webcast-ttcdn.byteimg.com/img/webcast/xigua_admin_badge_v2.png~tplv-obj.image"
1: "http://p1-webcast-ttcdn.byteimg.com/img/webcast/xigua_admin_badge_v2.png~tplv-obj.image"
2: "webcast/xigua_admin_badge_v2.png"
3: 16
4: 28
6: 3
}
21 {
1: "http://p1-webcast-ttcdn.byteimg.com/img/webcast/25_xigua_honor_level.png~tplv-obj.png"
1: "http://p9-webcast-ttcdn.byteimg.com/img/webcast/25_xigua_honor_level.png~tplv-obj.png"
2: "webcast/25_xigua_honor_level.png"
3: 16
4: 30
6: 1
7: "sslocal://webcast_webview?url=https%3A%2F%2Fwebcast.ixigua.com%2Ffalcon%2Fwebcast_xigua%2Fpage%2Fhonor_level%2Fuser%2Findex.html&hide_nav_bar=1&hide_status_bar=0&__live_platform__=webcast&type=fullscreen"
}
21 {
1: "http://p1-webcast-ttcdn.byteimg.com/img/webcast/xigua_fansclub_medal_15.png~tplv-obj.image"
1: "http://p6-webcast-ttcdn.byteimg.com/img/webcast/xigua_fansclub_medal_15.png~tplv-obj.image"
2: "webcast/xigua_fansclub_medal_15.png"
6: 7
8 {
1: "\345\247\232\345\247\232\347\220\263"
2: "#FFFFFF"
3: 15
}
}
22 {
1: 13
2: 86
3: 2
}
23 {
6: 25
19 {
1: "http://p1-webcast-ttcdn.byteimg.com/img/webcast/25_xigua_honor_level.png~tplv-obj.png"
1: "http://p9-webcast-ttcdn.byteimg.com/img/webcast/25_xigua_honor_level.png~tplv-obj.png"
2: "webcast/25_xigua_honor_level.png"
3: 16
4: 30
6: 1
7: "sslocal://webcast_webview?url=https%3A%2F%2Fwebcast.ixigua.com%2Ffalcon%2Fwebcast_xigua%2Fpage%2Fhonor_level%2Fuser%2Findex.html&hide_nav_bar=1&hide_status_bar=0&__live_platform__=webcast&type=fullscreen"
}
}
24 {
1 {
1: "\345\247\232\345\247\232\347\220\263"
2: 15
3: 1
4 {
1: "\010\002\022\342\001\nZhttp://p1-webcast-ttcdn.byteimg.com/img/webcast/xigua_fansclub_medal_15.png~tplv-obj.image\nZhttp://p6-webcast-ttcdn.byteimg.com/img/webcast/xigua_fansclub_medal_15.png~tplv-obj.image\022#webcast/xigua_fansclub_medal_15.png\0300 \226\001"
2: "\345\247\232\345\247\232\347\220\263"
}
6: 61788610240
}
}
32 {
2: 1
}
38: "0"
46: "MS4wLjABAAAAKkWCgUKAN3GtNdQ0jqr8zAt3KtIc9kAc1GaJ32VcH3E"
54: 3
61 {
1: "http://p9-webcast-ttcdn.byteimg.com/img/webcast/xigua_admin_badge_v2.png~tplv-obj.image"
1: "http://p1-webcast-ttcdn.byteimg.com/img/webcast/xigua_admin_badge_v2.png~tplv-obj.image"
2: "webcast/xigua_admin_badge_v2.png"
3: 16
4: 28
6: 3
}
61 {
1: "http://p1-webcast-ttcdn.byteimg.com/img/webcast/25_xigua_honor_level.png~tplv-obj.png"
1: "http://p9-webcast-ttcdn.byteimg.com/img/webcast/25_xigua_honor_level.png~tplv-obj.png"
2: "webcast/25_xigua_honor_level.png"
3: 16
4: 30
6: 1
7: "sslocal://webcast_webview?url=https%3A%2F%2Fwebcast.ixigua.com%2Ffalcon%2Fwebcast_xigua%2Fpage%2Fhonor_level%2Fuser%2Findex.html&hide_nav_bar=1&hide_status_bar=0&__live_platform__=webcast&type=fullscreen"
}
61 {
1: "http://p1-webcast-ttcdn.byteimg.com/img/webcast/xigua_fansclub_medal_15.png~tplv-obj.image"
1: "http://p6-webcast-ttcdn.byteimg.com/img/webcast/xigua_fansclub_medal_15.png~tplv-obj.image"
2: "webcast/xigua_fansclub_medal_15.png"
6: 7
8 {
1: "\345\247\232\345\247\232\347\220\263"
2: "#FFFFFF"
3: 15
}
}
}
2: 1
}
}
4 {
1: 1
11: "@\345\247\232\345\256\266\344\272\214\345\247\221\345\207\211 \346\210\221\345\211\215\345\244\251\346\235\245\346\267\261\345\234\263\344\272\206"
}
}
11: 31003
}
2 {
1: 5518138898
3: "\345\247\232\345\247\232\347\220\263\347\232\2042\345\247\220\345\244\253"
4: 1
9 {
1: "https://p1-dy.bytexservice.com/img/user-avatar/deb96a5e7e07f60a613531670a570736~300x300.image"
}
21 {
1: "http://p9-webcast-ttcdn.byteimg.com/img/webcast/xigua_admin_badge_v2.png~tplv-obj.image"
1: "http://p1-webcast-ttcdn.byteimg.com/img/webcast/xigua_admin_badge_v2.png~tplv-obj.image"
2: "webcast/xigua_admin_badge_v2.png"
3: 16
4: 28
6: 3
}
21 {
1: "http://p1-webcast-ttcdn.byteimg.com/img/webcast/25_xigua_honor_level.png~tplv-obj.png"
1: "http://p9-webcast-ttcdn.byteimg.com/img/webcast/25_xigua_honor_level.png~tplv-obj.png"
2: "webcast/25_xigua_honor_level.png"
3: 16
4: 30
6: 1
7: "sslocal://webcast_webview?url=https%3A%2F%2Fwebcast.ixigua.com%2Ffalcon%2Fwebcast_xigua%2Fpage%2Fhonor_level%2Fuser%2Findex.html&hide_nav_bar=1&hide_status_bar=0&__live_platform__=webcast&type=fullscreen"
}
21 {
1: "http://p1-webcast-ttcdn.byteimg.com/img/webcast/xigua_fansclub_medal_15.png~tplv-obj.image"
1: "http://p6-webcast-ttcdn.byteimg.com/img/webcast/xigua_fansclub_medal_15.png~tplv-obj.image"
2: "webcast/xigua_fansclub_medal_15.png"
6: 7
8 {
1: "\345\247\232\345\247\232\347\220\263"
2: "#FFFFFF"
3: 15
}
}
22 {
1: 13
2: 86
3: 2
}
23 {
6: 25
19 {
1: "http://p1-webcast-ttcdn.byteimg.com/img/webcast/25_xigua_honor_level.png~tplv-obj.png"
1: "http://p9-webcast-ttcdn.byteimg.com/img/webcast/25_xigua_honor_level.png~tplv-obj.png"
2: "webcast/25_xigua_honor_level.png"
3: 16
4: 30
6: 1
7: "sslocal://webcast_webview?url=https%3A%2F%2Fwebcast.ixigua.com%2Ffalcon%2Fwebcast_xigua%2Fpage%2Fhonor_level%2Fuser%2Findex.html&hide_nav_bar=1&hide_status_bar=0&__live_platform__=webcast&type=fullscreen"
}
}
24 {
1 {
1: "\345\247\232\345\247\232\347\220\263"
2: 15
3: 1
4 {
1 {
1: 2
2 {
1: "http://p1-webcast-ttcdn.byteimg.com/img/webcast/xigua_fansclub_medal_15.png~tplv-obj.image"
1: "http://p6-webcast-ttcdn.byteimg.com/img/webcast/xigua_fansclub_medal_15.png~tplv-obj.image"
2: "webcast/xigua_fansclub_medal_15.png"
3: 48
4: 150
}
}
2: "\345\247\232\345\247\232\347\220\263"
}
6: 61788610240
}
}
32 {
2: 1
}
38: "0"
46: "MS4wLjABAAAAKkWCgUKAN3GtNdQ0jqr8zAt3KtIc9kAc1GaJ32VcH3E"
54: 3
61 {
1: "http://p9-webcast-ttcdn.byteimg.com/img/webcast/xigua_admin_badge_v2.png~tplv-obj.image"
1: "http://p1-webcast-ttcdn.byteimg.com/img/webcast/xigua_admin_badge_v2.png~tplv-obj.image"
2: "webcast/xigua_admin_badge_v2.png"
3: 16
4: 28
6: 3
}
61 {
1: "http://p1-webcast-ttcdn.byteimg.com/img/webcast/25_xigua_honor_level.png~tplv-obj.png"
1: "http://p9-webcast-ttcdn.byteimg.com/img/webcast/25_xigua_honor_level.png~tplv-obj.png"
2: "webcast/25_xigua_honor_level.png"
3: 16
4: 30
6: 1
7: "sslocal://webcast_webview?url=https%3A%2F%2Fwebcast.ixigua.com%2Ffalcon%2Fwebcast_xigua%2Fpage%2Fhonor_level%2Fuser%2Findex.html&hide_nav_bar=1&hide_status_bar=0&__live_platform__=webcast&type=fullscreen"
}
61 {
1: "http://p1-webcast-ttcdn.byteimg.com/img/webcast/xigua_fansclub_medal_15.png~tplv-obj.image"
1: "http://p6-webcast-ttcdn.byteimg.com/img/webcast/xigua_fansclub_medal_15.png~tplv-obj.image"
2: "webcast/xigua_fansclub_medal_15.png"
6: 7
8 {
1: "\345\247\232\345\247\232\347\220\263"
2: "#FFFFFF"
3: 15
}
}
}
3: "@\345\247\232\345\256\266\344\272\214\345\247\221\345\207\211 \346\210\221\345\211\215\345\244\251\346\235\245\346\267\261\345\234\263\344\272\206"
9 {
1 {
1: "http://p3-webcast-ttcdn.byteimg.com/img/webcast/userlabel_regular_chat.png~tplv-obj.image"
1: "http://p6-webcast-ttcdn.byteimg.com/img/webcast/userlabel_regular_chat.png~tplv-obj.image"
2: "webcast/userlabel_regular_chat.png"
5: "#E0BCD4"
}
2: 11
}
}
3: 6902669212519992068
}
2: "1607153101490_6902670004165030044_6902669995575083008_1"
3: 1000
4: 1607153101490
5: "fetch_time:1607153101490|start_time:0|fetch_id:6902670004165030042|flag:0|seq:2080|next_cursor:1607153101490_6902670004165030044_6902669995575083008_1"

364
Demo/v926_leave.txt Normal file
View File

@ -0,0 +1,364 @@
1 {
1: "WebcastControlMessage"
2 {
1 {
1: "WebcastControlMessage"
2: 6902672961476774663
3: 6902629276546566925
4: 1607153789195
6: 1
}
2: 3
}
3: 6902672961476774663
}
1 {
1: "WebcastMemberMessage"
2 {
1 {
1: "WebcastMemberMessage"
2: 6902672961728662285
3: 6902629276546566925
6: 1
8 {
1: "live_room_enter_toast"
2: "{0:user} \346\235\245\344\272\206{1:string}"
3 {
1: "#de000000"
4: 400
}
4 {
1: 11
2 {
1: "#61000000"
4: 400
}
21 {
1 {
1: 2638466963214383
2: 1520817
3: "\350\213\261\345\256\207girl"
9 {
1: "https://sf3-ttcdn-tos.pstatp.com/img/user-avatar/a15266741ef770c810e18e5e6d073da9~300x300.image"
}
21 {
1: "http://p1-webcast-xgcdn.byteimg.com/img/webcast/3_xigua_honor_level.png~tplv-obj.png"
1: "http://p3-webcast-xgcdn.byteimg.com/img/webcast/3_xigua_honor_level.png~tplv-obj.png"
2: "webcast/3_xigua_honor_level.png"
3: 16
4: 30
6: 1
7: "sslocal://webcast_webview?url=https%3A%2F%2Fwebcast.ixigua.com%2Ffalcon%2Fwebcast_xigua%2Fpage%2Fhonor_level%2Fuser%2Findex.html&type=fullscreen&hide_nav_bar=1&hide_status_bar=0&__live_platform__=webcast"
}
22 {
1: 60
2: 120
}
23 {
6: 3
19 {
1: "http://p1-webcast-xgcdn.byteimg.com/img/webcast/3_xigua_honor_level.png~tplv-obj.png"
1: "http://p3-webcast-xgcdn.byteimg.com/img/webcast/3_xigua_honor_level.png~tplv-obj.png"
2: "webcast/3_xigua_honor_level.png"
3: 16
4: 30
6: 1
7: "sslocal://webcast_webview?url=https%3A%2F%2Fwebcast.ixigua.com%2Ffalcon%2Fwebcast_xigua%2Fpage%2Fhonor_level%2Fuser%2Findex.html&type=fullscreen&hide_nav_bar=1&hide_status_bar=0&__live_platform__=webcast"
}
}
24 {
1 {
4 {
1: "\010\000\022\000"
}
}
}
32: ""
38: "1520817"
46: "MS4wLjABAAAATNQnQSVI6ePiovK48LHJHvefLO_4RAmla1Hkn0GYTWc8oE5u0a0VZnmPM3VKnaSh"
54: 3
61 {
1: "http://p1-webcast-xgcdn.byteimg.com/img/webcast/3_xigua_honor_level.png~tplv-obj.png"
1: "http://p3-webcast-xgcdn.byteimg.com/img/webcast/3_xigua_honor_level.png~tplv-obj.png"
2: "webcast/3_xigua_honor_level.png"
3: 16
4: 30
6: 1
7: "sslocal://webcast_webview?url=https%3A%2F%2Fwebcast.ixigua.com%2Ffalcon%2Fwebcast_xigua%2Fpage%2Fhonor_level%2Fuser%2Findex.html&type=fullscreen&hide_nav_bar=1&hide_status_bar=0&__live_platform__=webcast"
}
}
}
}
}
9: 1
10: 1
11: 42000
}
2 {
1: 2638466963214383
2: 1520817
3: "\350\213\261\345\256\207girl"
9 {
1: "https://sf3-ttcdn-tos.pstatp.com/img/user-avatar/a15266741ef770c810e18e5e6d073da9~300x300.image"
}
21 {
1: "http://p1-webcast-xgcdn.byteimg.com/img/webcast/3_xigua_honor_level.png~tplv-obj.png"
1: "http://p3-webcast-xgcdn.byteimg.com/img/webcast/3_xigua_honor_level.png~tplv-obj.png"
2: "webcast/3_xigua_honor_level.png"
3: 16
4: 30
6: 1
7: "sslocal://webcast_webview?url=https%3A%2F%2Fwebcast.ixigua.com%2Ffalcon%2Fwebcast_xigua%2Fpage%2Fhonor_level%2Fuser%2Findex.html&type=fullscreen&hide_nav_bar=1&hide_status_bar=0&__live_platform__=webcast"
}
22 {
1: 60
2: 120
}
23 {
6: 3
19 {
1: "http://p1-webcast-xgcdn.byteimg.com/img/webcast/3_xigua_honor_level.png~tplv-obj.png"
1: "http://p3-webcast-xgcdn.byteimg.com/img/webcast/3_xigua_honor_level.png~tplv-obj.png"
2: "webcast/3_xigua_honor_level.png"
3: 16
4: 30
6: 1
7: "sslocal://webcast_webview?url=https%3A%2F%2Fwebcast.ixigua.com%2Ffalcon%2Fwebcast_xigua%2Fpage%2Fhonor_level%2Fuser%2Findex.html&type=fullscreen&hide_nav_bar=1&hide_status_bar=0&__live_platform__=webcast"
}
}
24 {
1 {
4 {
1 {
1: 0
2: ""
}
}
}
}
32: ""
38: "1520817"
46: "MS4wLjABAAAATNQnQSVI6ePiovK48LHJHvefLO_4RAmla1Hkn0GYTWc8oE5u0a0VZnmPM3VKnaSh"
54: 3
61 {
1: "http://p1-webcast-xgcdn.byteimg.com/img/webcast/3_xigua_honor_level.png~tplv-obj.png"
1: "http://p3-webcast-xgcdn.byteimg.com/img/webcast/3_xigua_honor_level.png~tplv-obj.png"
2: "webcast/3_xigua_honor_level.png"
3: 16
4: 30
6: 1
7: "sslocal://webcast_webview?url=https%3A%2F%2Fwebcast.ixigua.com%2Ffalcon%2Fwebcast_xigua%2Fpage%2Fhonor_level%2Fuser%2Findex.html&type=fullscreen&hide_nav_bar=1&hide_status_bar=0&__live_platform__=webcast"
}
}
3: 31
10: 1
14: "0\344\272\272"
18 {
1: "live_room_enter_toast"
2: "{0:user} \346\235\245\344\272\206{1:string}"
3 {
1: "#de000000"
4: 400
}
4 {
1: 11
2 {
1: "#61000000"
4: 400
}
21 {
1 {
1: 2638466963214383
2: 1520817
3: "\350\213\261\345\256\207girl"
9 {
1: "https://sf3-ttcdn-tos.pstatp.com/img/user-avatar/a15266741ef770c810e18e5e6d073da9~300x300.image"
}
21 {
1: "http://p1-webcast-xgcdn.byteimg.com/img/webcast/3_xigua_honor_level.png~tplv-obj.png"
1: "http://p3-webcast-xgcdn.byteimg.com/img/webcast/3_xigua_honor_level.png~tplv-obj.png"
2: "webcast/3_xigua_honor_level.png"
3: 16
4: 30
6: 1
7: "sslocal://webcast_webview?url=https%3A%2F%2Fwebcast.ixigua.com%2Ffalcon%2Fwebcast_xigua%2Fpage%2Fhonor_level%2Fuser%2Findex.html&type=fullscreen&hide_nav_bar=1&hide_status_bar=0&__live_platform__=webcast"
}
22 {
1: 60
2: 120
}
23 {
6: 3
19 {
1: "http://p1-webcast-xgcdn.byteimg.com/img/webcast/3_xigua_honor_level.png~tplv-obj.png"
1: "http://p3-webcast-xgcdn.byteimg.com/img/webcast/3_xigua_honor_level.png~tplv-obj.png"
2: "webcast/3_xigua_honor_level.png"
3: 16
4: 30
6: 1
7: "sslocal://webcast_webview?url=https%3A%2F%2Fwebcast.ixigua.com%2Ffalcon%2Fwebcast_xigua%2Fpage%2Fhonor_level%2Fuser%2Findex.html&type=fullscreen&hide_nav_bar=1&hide_status_bar=0&__live_platform__=webcast"
}
}
24 {
1 {
4 {
1 {
1: 0
2: ""
}
}
}
}
32: ""
38: "1520817"
46: "MS4wLjABAAAATNQnQSVI6ePiovK48LHJHvefLO_4RAmla1Hkn0GYTWc8oE5u0a0VZnmPM3VKnaSh"
54: 3
61 {
1: "http://p1-webcast-xgcdn.byteimg.com/img/webcast/3_xigua_honor_level.png~tplv-obj.png"
1: "http://p3-webcast-xgcdn.byteimg.com/img/webcast/3_xigua_honor_level.png~tplv-obj.png"
2: "webcast/3_xigua_honor_level.png"
3: 16
4: 30
6: 1
7: "sslocal://webcast_webview?url=https%3A%2F%2Fwebcast.ixigua.com%2Ffalcon%2Fwebcast_xigua%2Fpage%2Fhonor_level%2Fuser%2Findex.html&type=fullscreen&hide_nav_bar=1&hide_status_bar=0&__live_platform__=webcast"
}
}
}
}
}
}
3: 6902672961728662285
}
1 {
1: "WebcastMemberMessage"
2 {
1 {
1: "WebcastMemberMessage"
2: 6902672961866681091
3: 6902629276546566925
6: 1
8 {
1: "live_room_enter_toast"
2: "{0:user} \346\235\245\344\272\206{1:string}"
3 {
1: "#de000000"
4: 400
}
4 {
1: 11
2 {
1: "#61000000"
4: 400
}
21 {
1 {
1: 1015537608169303
3: "\347\224\250\346\210\267525366763846"
9 {
1: "https://sf1-ttcdn-tos.pstatp.com/img/mosaic-legacy/3791/5070639578~120x256.image"
}
22 {
1: 31
}
23 {
19: ""
}
24 {
1 {
4 {
1: "\010\000\022\000"
}
}
}
32: ""
38: "0"
46: "MS4wLjABAAAAXDp4yUHFF_sA3Uf9T4OPkW0dk-9SUYxbFNFrs7CcyhAcjYzdsYFYE5vuUdaHbo9R"
54: 3
}
}
}
}
9: 1
10: 1
11: 42000
}
2 {
1: 1015537608169303
3: "\347\224\250\346\210\267525366763846"
9 {
1: "https://sf1-ttcdn-tos.pstatp.com/img/mosaic-legacy/3791/5070639578~120x256.image"
}
22 {
1: 31
}
23 {
19: ""
}
24 {
1 {
4 {
1 {
1: 0
2: ""
}
}
}
}
32: ""
38: "0"
46: "MS4wLjABAAAAXDp4yUHFF_sA3Uf9T4OPkW0dk-9SUYxbFNFrs7CcyhAcjYzdsYFYE5vuUdaHbo9R"
54: 3
}
10: 1
14: "0\344\272\272"
18 {
1: "live_room_enter_toast"
2: "{0:user} \346\235\245\344\272\206{1:string}"
3 {
1: "#de000000"
4: 400
}
4 {
1: 11
2 {
1: "#61000000"
4: 400
}
21 {
1 {
1: 1015537608169303
3: "\347\224\250\346\210\267525366763846"
9 {
1: "https://sf1-ttcdn-tos.pstatp.com/img/mosaic-legacy/3791/5070639578~120x256.image"
}
22 {
1: 31
}
23 {
19: ""
}
24 {
1 {
4 {
1 {
1: 0
2: ""
}
}
}
}
32: ""
38: "0"
46: "MS4wLjABAAAAXDp4yUHFF_sA3Uf9T4OPkW0dk-9SUYxbFNFrs7CcyhAcjYzdsYFYE5vuUdaHbo9R"
54: 3
}
}
}
}
}
3: 6902672961866681091
}
2: "1607153789923_6902672963397497753_6902669995575083008_1"
3: 1000
4: 1607153789923
5: "fetch_time:1607153789923|start_time:0|fetch_id:6902672959102530453|flag:0|seq:2684|next_cursor:1607153789923_6902672963397497753_6902669995575083008_1"

13
LICENSE Normal file
View File

@ -0,0 +1,13 @@
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
Version 2, December 2004
Copyright (C) 2004 Sam Hocevar <sam@hocevar.net>
Everyone is permitted to copy and distribute verbatim or modified
copies of this license document, and changing it is allowed as long
as the name is changed.
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. You just DO WHAT THE FUCK YOU WANT TO.

View File

@ -1,18 +1,31 @@
# XiguaLiveDanmakuHelper
## 现在西瓜视频搜索接口无法使用建议开发者自己找到自己的用户ID写死加载即可
### 因西瓜直播弹幕接口换成了ProtoBuf已经尝试解析出了部分proto
[v7旧版本](https://github.com/q792602257/XiguaLiveDanmakuHelper/tree/v7)仍可用就是带动画的礼物不显示而已)
### 西瓜直播弹幕助手--控制台版
### 从安卓9.4版本后 *(大概是)* 发现需要连接Websocket才能获取弹幕且又是魔改protobuf搞不懂手动断开Websocket后才会轮询请求
界面版:[q792602257/XiguaDanmakuHelperGUI](https://github.com/q792602257/XiguaDanmakuHelperGUI "C# ver")
### ~~西瓜直播弹幕助手--界面版~~
> 界面版:[q792602257/XiguaDanmakuHelperGUI](https://github.com/q792602257/XiguaDanmakuHelperGUI "C# ver")
> #### 该项目已经荒废,除非你知道如何开发,否则不建议使用
### 西瓜直播弹幕接口```api.py```
> - 基于安卓8.1.6
> - 基于安卓9.6.6(96615)
### 西瓜直播弹幕助手--礼物端```WinMain.py```
### <s>计划更新</s>
### 西瓜直播弹幕助手--录播端```WebMain.py```
### 并没有呢,这段时间太忙了
> - 能够自动进行ffmpeg转码
> - 转码后自动上传至B站
> - 顺便还能自己清理录播的文件移动到一个位置执行shell命令上传百度云
> - 把录像文件分一定大小保存B站有限制但是不知道是多少
> - 少部分错误包容机制
> - 有一个简单的WEB页面及简单的控制接口
### ~~计划更新~~
### 随缘更新

View File

@ -4,13 +4,11 @@ from XiguaMessage_pb2 import GiftMessage
class Gift:
roomID = 0
giftList = {}
def __init__(self, json=None):
self.ID = 0
self.count = 0
self.amount = 0
self.user = None
self.isFinished = False
self.backupName = None
@ -31,10 +29,6 @@ class Gift:
def parse(self, json):
self.user = User(json)
if "common" in json and json["common"] is not None:
if Gift.roomID != int(json["common"]["room_id"]):
Gift.roomID = int(json["common"]["room_id"])
self.update()
if "extra" in json and json["extra"] is not None:
if "present_info" in json["extra"] and json["extra"]['present_info'] is not None:
self.ID = int(json["extra"]['present_info']['id'])
@ -42,21 +36,6 @@ class Gift:
elif "present_end_info" in json["extra"] and json["extra"]['present_end_info'] is not None:
self.ID = int(json["extra"]['present_end_info']['id'])
self.count = json["extra"]['present_end_info']['count']
if self.ID != 0 and self.ID in self.giftList:
self.amount = self.giftList[self.ID]['diamond_count'] * self.count
else:
self.update()
@classmethod
def update(cls):
p = requests.get("https://i.snssdk.com/videolive/gift/get_gift_list?room_id={roomID}"
"&version_code=800&device_platform=android".format(roomID=Gift.roomID))
d = p.json()
if "gift_info" not in d:
print("错误:礼物更新失败")
else:
for i in d["gift_info"]:
cls.addGift(i)
def isAnimate(self):
if self.ID != 0 and self.ID in self.giftList:
@ -68,7 +47,8 @@ class Gift:
return self.giftList[self.ID]["type"] == 2
return False
def _getGiftName(self):
@property
def name(self):
if self.ID in self.giftList:
return self.giftList[self.ID]["name"]
elif self.backupName is not None:
@ -77,13 +57,13 @@ class Gift:
return "未知礼物[{}]".format(self.ID)
def __str__(self):
return "{user} 送出的 {count}{name}".format(user=self.user, count=self.count, name=self._getGiftName())
return "{user} 送出的 {count}{name}".format(user=self.user, count=self.count, name=self.name)
def __unicode__(self):
return self.__str__()
def __repr__(self):
return "西瓜礼物【{}(ID:{})】".format(self._getGiftName(), self.ID)
return "西瓜礼物【{}(ID:{})】".format(self.name, self.ID)
@classmethod
def addGift(cls, _gift):

View File

@ -41,13 +41,17 @@ class User:
self.type = json["extra"]["user_room_auth_status"]["user_type"]
self.block = json["extra"]["user_room_auth_status"]["is_block"]
self.mute = json["extra"]["user_room_auth_status"]["is_silence"]
elif "user_info" in json and json["user_info"] is not None:
if "user_info" in json and json["user_info"] is not None:
self.ID = json['user_info']['user_id']
self.name = json['user_info']['name']
elif "anchor" in json and json["anchor"] is not None:
if "anchor" in json and json["anchor"] is not None:
if "user_info" in json["anchor"] and json["anchor"]['user_info'] is not None:
self.ID = json["anchor"]['user_info']['user_id']
self.name = json["anchor"]['user_info']['name']
if "user_id" in json:
self.ID = json["user_id"]
if "user_name" in json:
self.name = json["user_name"]
if self.type is None:
self.type = 0
if isinstance(self.level, str):

198
WebMain.py Normal file
View File

@ -0,0 +1,198 @@
import os
from glob import glob
from time import sleep
from flask_cors import CORS
from flask import Flask, jsonify, request, render_template, Response, send_file
import Common
import threading
from liveDownloader import run as RUN
app = Flask(__name__)
app.config['JSON_AS_ASCII'] = False
CORS(app, supports_credentials=True)
@app.route("/")
def index():
return render_template("index.html")
@app.route("/config", methods=["GET"])
def readConfig():
config = Common.config.copy()
return jsonify(config)
@app.route("/config", methods=["POST"])
def writeConfig():
# TODO : 完善
Common.appendOperation("更新配置")
Common.reloadConfig()
return jsonify({"message": "ok", "code": 200, "status": 0, "data": request.form})
@app.route("/force/not/upload", methods=["POST"])
def toggleForceNotUpload():
Common.forceNotUpload = not Common.forceNotUpload
Common.appendOperation("将强制不上传的值改为:{}".format(Common.forceNotUpload))
return jsonify({"message": "ok", "code": 200, "status": 0, "data": {
"forceNotUpload": Common.forceNotUpload,
}})
@app.route("/force/not/encode", methods=["POST"])
def toggleForceNotEncode():
Common.forceNotEncode = not Common.forceNotEncode
Common.appendOperation("将强制不编码的值改为:{}".format(Common.forceNotEncode))
return jsonify({"message": "ok", "code": 200, "status": 0, "data": {
"forceNotEncode": Common.forceNotEncode,
}})
@app.route("/force/not/download", methods=["POST"])
def toggleForceNotDownload():
Common.forceNotDownload = not Common.forceNotDownload
Common.appendOperation("将强制不下载的值改为:{}".format(Common.forceNotDownload))
return jsonify({"message": "ok", "code": 200, "status": 0, "data": {
"forceNotDownload": Common.forceNotDownload,
}})
@app.route("/force/not/broadcast", methods=["POST"])
def toggleForceNotBroadcast():
Common.forceNotBroadcasting = not Common.forceNotBroadcasting
return jsonify({"message": "ok", "code": 200, "status": 0, "data": {
"forceNotBroadcasting": Common.forceNotBroadcasting,
}})
@app.route("/force/start/encode", methods=["POST"])
def toggleForceStartEncodeThread():
Common.forceStartEncodeThread = True
Common.appendOperation("强制运行编码线程")
return jsonify({"message": "ok", "code": 200, "status": 0, "data": {
}})
@app.route("/force/start/upload", methods=["POST"])
def toggleForceStartUploadThread():
Common.forceStartUploadThread = True
Common.appendOperation("强制运行上传线程")
return jsonify({"message": "ok", "code": 200, "status": 0, "data": {
}})
@app.route("/force/start/clean", methods=["POST"])
def startForceCleanDisk():
Common.doClean(True)
Common.appendOperation("强制执行清理程序")
return jsonify({"message": "ok", "code": 200, "status": 0, "data": {
}})
@app.route("/encode/insert", methods=["POST"])
def insertEncode():
if "filename" in request.form and os.path.exists(request.form["filename"]):
Common.appendOperation("添加编码文件:{}".format(request.form["filename"]))
Common.encodeQueue.put(request.form["filename"])
return jsonify({"message": "ok", "code": 200, "status": 0})
else:
return jsonify({"message": "no filename specific", "code": 400, "status": 1})
@app.route("/upload/insert", methods=["POST"])
def insertUpload():
if "filename" in request.form and os.path.exists(request.form["filename"]):
Common.appendOperation("添加上传文件:{}".format(request.form["filename"]))
Common.uploadQueue.put(request.form["filename"])
return jsonify({"message": "ok", "code": 200, "status": 0})
else:
return jsonify({"message": "no filename specific", "code": 400, "status": 1})
@app.route("/upload/discard", methods=["POST"])
def discardUpload():
Common.uploadQueue.empty()
Common.b.clear()
return jsonify({"message": "ok", "code": 200, "status": 0})
@app.route("/upload/finish", methods=["POST"])
def finishUpload():
Common.appendOperation("设置当前已完成上传")
Common.uploadQueue.put(True)
return jsonify({"message": "ok", "code": 200, "status": 0})
@app.route("/stats", methods=["GET"])
def getAllStats():
return jsonify({"message": "ok", "code": 200, "status": 0, "data": Common.collectInfomation()})
@app.route("/stats/device", methods=["GET"])
def getDeviceStatus():
return jsonify({"message": "ok", "code": 200, "status": 0, "data": {
"status": Common.getCurrentStatus(),
}})
@app.route("/stats/config", methods=["GET"])
def getConfigStats():
return jsonify({"message": "ok", "code": 200, "status": 0, "data": {
"config": {
"forceNotBroadcasting": Common.forceNotBroadcasting,
"forceNotDownload": Common.forceNotDownload,
"forceNotUpload": Common.forceNotUpload,
"forceNotEncode": Common.forceNotEncode,
"downloadOnly": Common.config['dlO'],
}
}})
@app.route("/account/reLogin", methods=["POST"])
def accountRelogin():
res = Common.loginBilibili(True)
return jsonify({"message": "ok", "code": 200, "status": 0, "data": {"result": res}})
@app.route("/files/", methods=["GET"])
def fileIndex():
a = []
for i in (glob("*.mp4") + glob("*.flv")):
a.append({
"name": i,
"size": Common.parseSize(os.path.getsize(i))
})
return render_template("files.html", files=a)
@app.route("/files/download/<path>", methods=["GET"])
def fileDownload(path):
if not (".mp4" in path or ".flv" in path):
return Response(status=404)
if os.path.exists(path):
return send_file(path, as_attachment=True)
else:
return Response(status=404)
def SubThread():
t = threading.Thread(target=RUN, args=())
t.setDaemon(True)
t.start()
while True:
if t.is_alive():
sleep(240)
else:
t = threading.Thread(target=RUN, args=())
t.setDaemon(True)
t.start()
if not app.debug:
p = threading.Thread(target=SubThread)
p.setDaemon(True)
p.start()
if __name__ == "__main__":
app.run()

View File

@ -10,6 +10,7 @@ from Struct.Gift import Gift
from Struct.Chat import Chat
from Struct.Lottery import Lottery
from api import XiGuaLiveApi as Api
from api import public_hello
import msvcrt
import ctypes
@ -121,10 +122,11 @@ class WinMain(Api):
return "人气:{} --弹幕助手 by JerryYan".format(self.roomPopularity)
def onMessage(self, msg: str):
set_cmd_text_color(BACKGROUND_BLACK | FOREGROUND_DARKGRAY)
print("消息 : ", msg, end="")
resetColor()
print()
if SHOW_ALL:
set_cmd_text_color(BACKGROUND_BLACK | FOREGROUND_DARKGRAY)
print("消息 : ", msg, end="")
resetColor()
print()
def onJoin(self, user: User):
set_cmd_text_color(BACKGROUND_WHITE | FOREGROUND_BLACK)
@ -188,16 +190,13 @@ def warning(*args):
if __name__ == "__main__":
name = "永恒de草薙"
resetColor()
print("西瓜直播礼物助手 by JerryYan")
print("接口版本8.1.6")
if len(sys.argv) > 1:
name = "97621754276"
if len(sys.argv) > 2:
if sys.argv[-1] == "a":
SHOW_ALL = True
name = sys.argv[1]
if len(sys.argv) > 2:
SHOW_ALL = sys.argv[2] == "a"
else:
name = readInput("请输入主播用户名,默认为", name, 3)
resetColor()
public_hello()
print("搜索【", name, "", end="\t", flush=True)
api = WinMain(name)
if not api.isValidUser:

View File

@ -4,13 +4,13 @@ block_cipher = None
a = Analysis(['WinMain.py'],
pathex=['E:\\XiGuaLiveDanmakuHelper',r'C:\\Program Files (x86)\\Windows Kits\\10\\Redist\\10.0.18362.0\\ucrt\\DLLs\\x86'],
pathex=['E:\\XiGuaLiveDanmakuHelper',r'C:\\Program Files (x86)\\Windows Kits\\10\\Redist\\10.0.17763.0\\ucrt\\DLLs\\x86'],
binaries=[],
datas=[],
hiddenimports=[],
hookspath=[],
runtime_hooks=[],
excludes=[],
excludes=["xml", "_tkinter", "pydoc", "lib2to3"],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,

0
access_token Normal file
View File

227
api.py
View File

@ -5,7 +5,6 @@ from Struct.MemberMsg import MemberMsg
from Struct.User import User
from Struct.Gift import Gift
from Struct.Chat import Chat
from Struct.Lottery import Lottery
import requests
import time
from datetime import datetime, timedelta
@ -13,28 +12,68 @@ from Xigua_pb2 import XiguaLive
from XiguaMessage_pb2 import FansClubMessage, SocialMessage
DEBUG = False
# 自己抓的自己设备的参数,建议开发者自己抓一个长期使用
# 如果有大佬破解初次激活设备时的数据也行,可以自己生成一堆用
CUSTOM_INFO = {
'iid': "3993882704224472",
'device_id': "71008241150",
'cdid': "f93b3708-3fec-498f-9080-723a5679f4c0",
'openudid': "4aeb1e2b627697be",
# 'aid': "32", # 是一个不变的值
'channel': "xiaomi",
'device_brand': "Xiaomi",
'device_type': "MI+9",
'os_api': "29",
'os_version': "10",
'abi': "armeabi-v7a",
'dpi': "480",
'resolution': "1080*2217",
'rom_version': "miui_V12_V12.0.6.0.QFACNXM",
}
VERSION_INFO = {
'app_name': "video_article",
'version_code': "966",
'version_code_full': "96615",
'version_name': "9.6.6",
'ab_version': "668851,2678488,668858,2678385,668859,2678471,668856,2678470,668855,2678439,668854,994679,"
"2678460,2713070,2738381,2744004,668853,2678466,668852,2678435,2625016",
'manifest_version_code': "566",
'tma_jssdk_version': "2010000",
'oaid': "693ea85657ef38ca",
}
COMMON_GET_PARAM = (
"&iid=96159232732&device_id=55714661189&channel=xiaomi&aid=32&app_name=video_article&version_code=816"
"&version_name=8.1.6&device_platform=android&ab_version=941090,785218,668858,1046292,1073579,830454,956074,929436,"
"797199,1135476,1179370,994679,959010,900042,1113833,668854,1193963,901277,1043330,1038721,994822,1002058,1230687,"
"1189797,1143356,1143441,1143501,1143698,1143713,1371009,1243997,1392586,1395695,1395486,1398858,668852,668856,"
"668853,1186421,668851,668859,999124,668855,1039075&device_type=MI+8+SE&device_brand=Xiaomi&language=zh"
"&os_api=28&os_version=9&openudid=70d6668d41512c39&manifest_version_code=412&update_version_code=81606"
"&_rticket={TIMESTAMP:.0f}&cdid_ts={TIMESTAMP:.0f}&fp=a_fake_fp&tma_jssdk_version=1290000"
"&cdid=ed4295e8-5d9a-4cb9-b2a2-04009a3baa2d&oaid=a625f466e0975d42")
"&iid={iid}&device_id={device_id}&ac=wifi&channel={channel}&aid=32&app_name={app_name}&version_code={version_code}&"
"version_name={version_name}&device_platform=android&ab_version={ab_version}&ssmix=a&device_type={device_type}&"
"device_brand={device_brand}&language=zh&os_api={os_api}&os_version={os_version}&openudid={openudid}&"
"manifest_version_code={manifest_version_code}&resolution={resolution}&dpi={dpi}&"
"update_version_code={version_code_full}&_rticket={{TIMESTAMP:.0f}}&"
"cdid_ts={{TIMESTAMP:.0f}}&host_abi={abi}&tma_jssdk_version={tma_jssdk_version}&"
"rom_version={rom_version}&cdid={cdid}&oaid={oaid}").format_map({**VERSION_INFO, **CUSTOM_INFO})
WEBCAST_GET_PARAMS = "webcast_sdk_version=1990&webcast_language=zh&webcast_locale=zh_CN&webcast_gps_access=1"
ROOM_ENTER_POST_PARAMS = (
"room_id={roomId}&hold_living_room=1&is_login=0&enter_from_uid_by_shared=0&video_id=0&"
"scenario=0&enter_type=click&enter_source=click_pgc_WITHIN_pgc-head_portrait&live_room_mode=0")
SEARCH_USER_API = (
"https://security.snssdk.com/video/app/search/live/?format=json&search_sug=0&forum=0&m_tab=live&is_native_req=0"
"&offset=0&from=live&en_qc=1&pd=xigua_live&ssmix=a{COMMON}&keyword={keyword}")
USER_INFO_API = "https://is.snssdk.com/video/app/user/home/v7/?to_user_id={userId}{COMMON}"
ROOM_INFO_API = ("https://webcast3.ixigua.com/webcast/room/enter/?room_id={roomId}&webcast_sdk_version=1350"
"&webcast_language=zh&webcast_locale=zh_CN&pack_level=4{COMMON}")
DANMAKU_GET_API = ("https://webcast3.ixigua.com/webcast/room/{roomId}/_fetch_message_polling/?webcast_sdk_version=1350"
"&webcast_language=zh&webcast_locale=zh_CN{COMMON}")
GIFT_DATA_API = ("https://webcast.ixigua.com/webcast/gift/list/?room_id={roomId}&fetch_giftlist_from=2"
"&webcast_sdk_version=1350&webcast_language=zh&webcast_locale=zh_CN{COMMON}")
"https://search5-search-lq.ixigua.com/video/app/search/live/?format=json&keyword_type=search_subtab_switch"
"&fss=search_subtab_switch&target_channel=video_search&keyword_type=search_subtab_switch&offset=0&count=10"
"&search_sug=1&forum=1&is_native_req=0&m_tab=video&pd=user&tab=user&_s_tma=SEARCH_STANDARD.list.fe_get_data"
'&_s_page_sub_route=/&_s_ec={{"filterDataType":[],"reserveFilterBar":true}}&__use_xigua_native_bridge_fetch__=1'
'&ab_param={{"is_show_filter_feature": 1, "is_hit_new_ui": 1}}'
"&search_start_time={TIMESTAMP:.0f}&from=live&en_qc=1&pd=xigua_live&ssmix=a{COMMON}&keyword={keyword}")
USER_INFO_API = "https://ib-lq.snssdk.com/video/app/user/userhome/v8/?to_user_id={userId}{COMMON}"
ROOM_INFO_API = "https://webcast5-normal-c-lq.ixigua.com/webcast/room/enter/?{WEBCAST}{COMMON}"
DANMAKU_GET_API = "https://webcast5-normal-c-lq.ixigua.com/webcast/im/fetch/?{WEBCAST}{COMMON}"
GIFT_DATA_API = ("https://webcast5-normal-c-hl.ixigua.com/webcast/gift/list/?room_id={roomId}&to_room_id={roomId}&"
"gift_scene=1&fetch_giftlist_from=2&current_network_quality_info={{}}&"
"{WEBCAST}{COMMON}")
COMMON_HEADERS = {
"sdk-version": '1',
"User-Agent": "Dalvik/2.1.0 (Linux; U; Android 9) VideoArticle/8.1.6 cronet/TTNetVersion:b97574c0 2019-09-24",
"x-vc-bdturing-sdk-version": "2.0.1",
"sdk-version": '2',
"passport-sdk-version": "21",
"User-Agent": "Dalvik/2.1.0 (Linux; U; Android 10; MI 9 MIUI/V12.0.6.0.QFACNXM) VideoArticle/9.6.6 "
"cronet/TTNetVersion:4b936afe 2021-01-13 QuicVersion:47946d2a 2020-10-14",
"X-SS-STUB": "74103A7F40FDC66E0675705DCA2C3A77",
"X-SS-DP": "32",
"Accept-Encoding": "gzip, deflate"
}
@ -51,6 +90,7 @@ class XiGuaLiveApi:
name = "永恒de草薙"
self.broadcaster = None
self.isValidUser = False
self.name = str(name)
if type(name) == User:
self.broadcaster = name
self.name = name.name
@ -64,7 +104,6 @@ class XiGuaLiveApi:
self._rawRoomInfo = {}
self.roomID = 0
self.roomPopularity = 0
self.lottery = None
self.s = requests.session()
self.s.headers.update(COMMON_HEADERS)
self._updRoomAt = datetime.fromtimestamp(0)
@ -86,6 +125,8 @@ class XiGuaLiveApi:
self.roomPopularity = _data["data"]["popularity"]
def getJson(self, url, **kwargs):
if "timeout" not in kwargs:
kwargs["timeout"] = 10
try:
p = self.s.get(url, **kwargs)
except Exception as e:
@ -107,6 +148,8 @@ class XiGuaLiveApi:
return None
def postJson(self, url, data, **kwargs):
if "timeout" not in kwargs:
kwargs["timeout"] = 10
try:
p = self.s.post(url, data=data, **kwargs)
except Exception as e:
@ -114,6 +157,7 @@ class XiGuaLiveApi:
if DEBUG:
print("POST")
print("URL", url)
print("DATA", data)
print("ERR ", e.__str__())
return None
try:
@ -121,9 +165,11 @@ class XiGuaLiveApi:
except Exception as e:
print("解析请求失败")
if DEBUG:
print("GET JSON")
print("POST JSON")
print("URL", url)
print("DATA", data)
print("CNT", p.text)
print("HDR", p.headers)
print("ERR ", e.__str__())
return None
@ -220,13 +266,6 @@ class XiGuaLiveApi:
print("消息 :", "主播离开了")
self.updRoomInfo()
def onLottery(self, i: Lottery):
"""
中奖的内容
:param i:
"""
print("中奖消息 :", i)
def _checkUsernameIsMatched(self, compare=None):
"""
验证主播名字是自己想要的那个
@ -237,35 +276,41 @@ class XiGuaLiveApi:
compare = self.broadcaster
if self.name is None or compare is None:
return False
return self.name == compare.__str__() or compare.__str__() in self.name or self.name in compare.__str__()
return self.name == compare.__str__() or compare.__repr__() in self.name or self.name in compare.__repr__()
def _forceSearchUser(self):
"""
搜索主播名
:return:
"""
_formatData = {"COMMON": COMMON_GET_PARAM, "TIMESTAMP": time.time() * 1000, "keyword": self.name}
_url = SEARCH_USER_API.format_map(_formatData).format_map(_formatData)
_formatData = {"TIMESTAMP": time.time() * 1000, "keyword": self.name}
_COMMON = COMMON_GET_PARAM.format_map(_formatData)
_formatData['COMMON'] = _COMMON
_url = SEARCH_USER_API.format_map(_formatData)
d = self.getJson(_url)
if d is None:
print("搜索接口请求失败")
return False
self.broadcaster = None
self.isValidUser = False
self.isLive = False
if "data" in d and d["data"] is not None:
for i in d["data"]:
if self.broadcaster is not None:
break
if i["block_type"] != 0:
if i["card_type"] != 3:
continue
if "cells" not in i or len(i["cells"]) == 0:
if "search_data" not in i or len(i["search_data"]) == 0:
break
for _j in i["cells"]:
_user = User(_j)
if self._checkUsernameIsMatched(_user):
self.isValidUser = True
self.broadcaster = _user
break
for _j in i["search_data"]:
if "room" in _j:
_user = User(_j["room"])
self.roomID = _j["room"]["room_id"]
self.isLive = _j["room"]["is_living"]
if self._checkUsernameIsMatched(_user):
self.isValidUser = True
self.broadcaster = _user
break
self._updRoomAt = datetime.now()
return self._updateUserInfo()
@ -277,41 +322,56 @@ class XiGuaLiveApi:
if self.broadcaster is None:
self.isValidUser = False
return False
_formatData = {"COMMON": COMMON_GET_PARAM, "TIMESTAMP": time.time() * 1000, "userId": self.broadcaster.ID}
_url = USER_INFO_API.format_map(_formatData).format_map(_formatData)
self.isLive = False
_formatData = {"TIMESTAMP": time.time() * 1000, "userId": self.broadcaster.ID}
_COMMON = COMMON_GET_PARAM.format_map(_formatData)
_formatData['COMMON'] = _COMMON
_url = USER_INFO_API.format_map(_formatData)
d = self.getJson(_url)
if d is None:
print("获取用户信息失败")
return False
self.isValidUser = d["status"] == 0
if "user_info" not in d and d["user_info"] is None:
_d = d.get('data', {})
if "user_home_info" not in _d and _d['user_home_info']['user_info'] is None:
self.apiChangedError("Api发生改变请及时联系我", d)
return False
self.broadcaster = User(d)
self._updRoomAt = datetime.now()
self.broadcaster = User(_d['user_home_info'])
if not self._checkUsernameIsMatched():
self.isLive = False
return False
self.isLive = d["user_info"]["is_living"]
self._updRoomAt = datetime.now()
self._rawRoomInfo = d["user_info"]['live_info']
self.isLive = 'user_live_info_list' in _d
if self.isLive and len(_d['user_live_info_list']) != 0:
# 既然有长度,默认个0应该没事
self._rawRoomInfo = _d['user_live_info_list'][0]['live_info']
else:
self.isLive = False
if self.isLive:
self.roomID = d["user_info"]['live_info']['room_id']
# 处理抽奖事件
l = Lottery(self._rawRoomInfo)
if l.isActive:
# 因为现在每个房间只能同时开启一个抽奖,所以放一个就行了
self.lottery = l
return True
self.roomID = self._rawRoomInfo['room_id']
return self._getRoomInfo(True)
return self.isLive
def _getRoomInfo(self, force=False):
if self.roomID == 0:
if self.roomID == 0 or not self.roomID:
self.isLive = False
return False
if not force and (self._updRoomAt + timedelta(minutes=10) > datetime.now()):
if (self._updRoomAt + timedelta(minutes=3) > datetime.now()) and not force:
return self.isLive
_formatData = {"COMMON": COMMON_GET_PARAM, "TIMESTAMP": time.time() * 1000, "roomId": self.roomID}
_url = ROOM_INFO_API.format_map(_formatData).format_map(_formatData)
d = self.getJson(_url)
_formatData = {"TIMESTAMP": time.time() * 1000}
_COMMON = COMMON_GET_PARAM.format_map(_formatData)
_formatData['COMMON'] = _COMMON
_formatData['WEBCAST'] = WEBCAST_GET_PARAMS
_url = ROOM_INFO_API.format_map(_formatData)
_postData = ROOM_ENTER_POST_PARAMS.format_map({'roomId': self.roomID})
_headers = {"response-format": "json",
'Connection': "keep-alive",
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
'Content-Length': str(len(_postData)),
**COMMON_HEADERS,
}
d = self.postJson(_url, _postData, headers=_headers)
self.isLive = False
if d is None:
print("获取房间信息接口请求失败")
return False
@ -319,10 +379,9 @@ class XiGuaLiveApi:
print("接口提示:【{}".format(d["data"]["message"]))
return False
self._rawRoomInfo = d["data"]
self.isLive = d["data"]["status"] == 2
self.isLive = self._rawRoomInfo["status"] == 2
self._updRoomAt = datetime.now()
self._updateRoomPopularity(d)
Gift.roomID = self.roomID
return self.isLive
def updRoomInfo(self, force=False):
@ -341,15 +400,15 @@ class XiGuaLiveApi:
def updGiftInfo(self):
self.updRoomInfo()
_formatData = {"COMMON": COMMON_GET_PARAM, "TIMESTAMP": time.time() * 1000, "roomId": self.roomID}
_url = GIFT_DATA_API.format_map(_formatData).format_map(_formatData)
_formatData = {"TIMESTAMP": time.time() * 1000, "roomId": self.roomID}
_COMMON = COMMON_GET_PARAM.format_map(_formatData)
_formatData['COMMON'] = _COMMON
_formatData['WEBCAST'] = WEBCAST_GET_PARAMS
_url = GIFT_DATA_API.format_map(_formatData)
d = self.getJson(_url)
Gift.roomID = self.roomID
if d is None or d["status_code"] != 0:
Gift.update()
elif 'pages' not in d["data"]:
Gift.update()
else:
return "异常"
elif 'pages' in d["data"]:
for _page in d["data"]['pages']:
if 'gifts' in _page:
for _gift in _page['gifts']:
@ -361,10 +420,17 @@ class XiGuaLiveApi:
获取弹幕
"""
self.updRoomInfo()
_formatData = {"COMMON": COMMON_GET_PARAM, "TIMESTAMP": time.time() * 1000, "roomId": self.roomID}
_url = DANMAKU_GET_API.format_map(_formatData).format_map(_formatData)
p = self.s.post(_url, data="cursor={cursor}&resp_content_type=protobuf&live_id=3&user_id=0&identity=audience"
"&internal_ext={ext}".format_map({"cursor": self._cursor, "ext": self._ext}),
if not self.isLive:
return
_formatData = {"TIMESTAMP": time.time() * 1000, "roomId": self.roomID}
_COMMON = COMMON_GET_PARAM.format_map(_formatData)
_formatData['COMMON'] = _COMMON
_formatData['WEBCAST'] = WEBCAST_GET_PARAMS
_url = DANMAKU_GET_API.format_map(_formatData)
p = self.s.post(_url, data="room_id={roomId}&fetch_rule=0&cursor={cursor}&"
"resp_content_type=protobuf&live_id=3&user_id=0&identity=audience&"
"last_rtt=85&internal_ext={ext}"
.format_map({"roomId":self.roomID, "cursor": self._cursor, "ext": self._ext}),
headers={"Content-Type": "application/x-www-form-urlencoded"})
if p.status_code != 200:
return
@ -382,6 +448,9 @@ class XiGuaLiveApi:
elif _each.method == "WebcastChatMessage":
_chat = Chat(_each.raw)
self.onChat(_chat)
elif _each.method == "WebcastControlMessage":
# 下播的时候会有个这个
self.onLeave(None)
elif _each.method == "WebcastSocialMessage":
_socialMessage = SocialMessage()
_socialMessage.ParseFromString(_each.raw)
@ -398,26 +467,24 @@ class XiGuaLiveApi:
self.onMessage(_fansClubMessage.content)
else:
pass
# 更新抽奖信息
if self.lottery is not None and self.lottery.ID != 0:
self.lottery.update()
if self.lottery.isFinished:
self.onLottery(self.lottery)
self.lottery = None
@property
def updateAt(self):
return self._updRoomAt
def public_hello():
print("西瓜直播弹幕助手 by JerryYan")
print("接口版本:{version_name}({version_code_full})".format_map(VERSION_INFO))
if __name__ == "__main__":
name = "永恒de草薙"
if len(sys.argv) > 2:
if sys.argv[-1] == "d":
DEBUG = True
name = sys.argv[1]
print("西瓜直播弹幕助手 by JerryYan")
print("接口版本8.1.6")
public_hello()
print("搜索【", name, "", end="\t", flush=True)
api = XiGuaLiveApi(name)
if not api.isValidUser:

121
bilibili.py Normal file
View File

@ -0,0 +1,121 @@
from bilibiliuploader import core, VideoPart
class Bilibili:
def __init__(self):
self.access_token = ""
self.session_id = ""
self.user_id = ""
self.parts = []
def login(self):
from Common import appendOperation
with open("access_token", "r") as f:
self.access_token = f.read(64).strip()
self.session_id, self.user_id, expires = core.login_by_access_token(self.access_token)
appendOperation("B站登录UID【{}】,过期时间【{}".format(self.user_id, expires))
def upload(self,
parts,
title,
tid,
tag,
desc,
source='',
cover='',
no_reprint=1,
):
"""
:param parts: e.g. VideoPart('part path', 'part title', 'part desc'), or [VideoPart(...), VideoPart(...)]
:type parts: VideoPart or list<VideoPart>
:param title: video's title
:type title: str
:param tid: video type, see: https://member.bilibili.com/x/web/archive/pre
or https://github.com/uupers/BiliSpider/wiki/%E8%A7%86%E9%A2%91%E5%88%86%E5%8C%BA%E5%AF%B9%E5%BA%94%E8%A1%A8
:type tid: int
:param tag: video's tag
:type tag: list<str>
:param desc: video's description
:type desc: str
:param source: (optional) 转载地址
:type source: str
:param cover: (optional) cover's URL, use method *cover_up* to get
:type cover: str
:param no_reprint: (optional) 0=可以转载, 1=禁止转载(default)
:type no_reprint: int
"""
self.preUpload(parts)
self.finishUpload(title, tid, tag, desc, source, cover, no_reprint)
self.clear()
def preUpload(self, parts, max_retry=5):
"""
:param max_retry:
:param parts: e.g. VideoPart('part path', 'part title', 'part desc'), or [VideoPart(...), VideoPart(...)]
:type parts: VideoPart or list<VideoPart>
"""
from Common import appendUploadStatus, modifyLastUploadStatus, appendError
if not isinstance(parts, list):
parts = [parts]
def log_status(video_part: VideoPart, chunks_index: int, chunks_num: int):
modifyLastUploadStatus("Uploading >{}< @ {:.2f}%".format(video_part.path, 100.0 * chunks_index / chunks_num))
for part in parts:
appendUploadStatus("Start Uploading >{}<".format(part.path))
status = core.upload_video_part(self.access_token, self.session_id, self.user_id, part, max_retry, cb=log_status)
if status:
# 上传完毕
modifyLastUploadStatus("Upload >{}< Finished{}".format(part.path, part.server_file_name))
self.parts.append(part)
else:
modifyLastUploadStatus("Upload >{}< Failed".format(part.path))
def finishUpload(self,
title,
tid,
tag,
desc,
source='',
cover='',
no_reprint=1,
):
"""
:param title: video's title
:type title: str
:param tid: video type, see: https://member.bilibili.com/x/web/archive/pre
or https://github.com/uupers/BiliSpider/wiki/%E8%A7%86%E9%A2%91%E5%88%86%E5%8C%BA%E5%AF%B9%E5%BA%94%E8%A1%A8
:type tid: int
:param tag: video's tag
:type tag: list<str>
:param desc: video's description
:type desc: str
:param source: (optional) 转载地址
:type source: str
:param cover: (optional) cover's URL, use method *cover_up* to get
:type cover: str
:param no_reprint: (optional) 0=可以转载, 1=禁止转载(default)
:type no_reprint: int
:param copyright: (optional) 0=转载的, 1=自制的(default)
:type copyright: int
"""
from Common import appendUploadStatus, modifyLastUploadStatus, appendError
if len(self.parts) == 0:
return
appendUploadStatus("[{}]投稿中,请稍后".format(title))
copyright = 2 if source else 1
try:
avid, bvid = core.upload(self.access_token, self.session_id, self.user_id, self.parts, copyright,
title=title, tid=tid, tag=','.join(tag), desc=desc, source=source, cover=cover, no_reprint=no_reprint)
modifyLastUploadStatus("[{}]投稿成功AVID【{}BVID【{}".format(title, avid, bvid))
self.clear()
except Exception as e:
modifyLastUploadStatus("[{}]投稿失败".format(title))
appendError(e)
def reloadFromPrevious(self):
...
def clear(self):
self.parts = []

View File

@ -0,0 +1,4 @@
修改自
https://github.com/FortuneDayssss/BilibiliUploader/
LICENSEGPL

View File

@ -0,0 +1,4 @@
from .bilibiliuploader import BilibiliUploader
from .core import VideoPart
__version__ = '0.0.6'

View File

@ -0,0 +1,118 @@
import bilibiliuploader.core as core
from bilibiliuploader.util import cipher
import json
class BilibiliUploader():
def __init__(self):
self.access_token = None
self.refresh_token = None
self.sid = None
self.mid = None
def login(self, username, password):
code, self.access_token, self.refresh_token, self.sid, self.mid, _ = core.login(username, password)
if code != 0: # success
print("login fail, error code = {}".format(code))
def login_by_access_token(self, access_token, refresh_token=None):
self.access_token = access_token
self.refresh_token = refresh_token
self.sid, self.mid, _ = core.login_by_access_token(access_token)
def login_by_access_token_file(self, file_name):
with open(file_name, "r") as f:
login_data = json.loads(f.read())
self.access_token = login_data["access_token"]
self.refresh_token = login_data["refresh_token"]
self.sid, self.mid, _ = core.login_by_access_token(self.access_token)
def save_login_data(self, file_name=None):
login_data = json.dumps(
{
"access_token": self.access_token,
"refresh_token": self.refresh_token
}
)
try:
with open(file_name, "w+") as f:
f.write(login_data)
finally:
return login_data
def upload(self,
parts,
copyright: int,
title: str,
tid: int,
tag: str,
desc: str,
source: str = '',
cover: str = '',
no_reprint: int = 0,
open_elec: int = 1,
max_retry: int = 5,
thread_pool_workers: int = 1):
return core.upload(self.access_token,
self.sid,
self.mid,
parts,
copyright,
title,
tid,
tag,
desc,
source,
cover,
no_reprint,
open_elec,
max_retry,
thread_pool_workers)
def edit(self,
avid=None,
bvid=None,
parts=None,
insert_index=None,
copyright=None,
title=None,
tid=None,
tag=None,
desc=None,
source=None,
cover=None,
no_reprint=None,
open_elec=None,
max_retry: int = 5,
thread_pool_workers: int = 1):
if not avid and not bvid:
print("please provide avid or bvid")
return None, None
if not avid:
avid = cipher.bv2av(bvid)
if not isinstance(parts, list):
parts = [parts]
if type(avid) is str:
avid = int(avid)
core.edit_videos(
self.access_token,
self.sid,
self.mid,
avid,
bvid,
parts,
insert_index,
copyright,
title,
tid,
tag,
desc,
source,
cover,
no_reprint,
open_elec,
max_retry,
thread_pool_workers
)

790
bilibiliuploader/core.py Normal file
View File

@ -0,0 +1,790 @@
import requests
from datetime import datetime
from bilibiliuploader.util import cipher as cipher
from urllib import parse
import os
import math
import hashlib
from bilibiliuploader.util.retry import Retry
from concurrent.futures import ThreadPoolExecutor, as_completed
import base64
# From PC ugc_assisstant
# APPKEY = 'aae92bc66f3edfab'
# APPSECRET = 'af125a0d5279fd576c1b4418a3e8276d'
APPKEY = '1d8b6e7d45233436'
APPSECRET = '560c52ccd288fed045859ed18bffd973'
LOGIN_APPKEY = '783bbb7264451d82'
# upload chunk size = 2MB
CHUNK_SIZE = 2 * 1024 * 1024
# captcha
CAPTCHA_RECOGNIZE_URL = "NOT SUPPORT"
class VideoPart:
"""
Video Part of a post.
每个对象代表一个分P
Attributes:
path: file path in local file system.
title: title of the video part.
desc: description of the video part.
server_file_name: file name in bilibili server. generated by pre-upload API.
"""
def __init__(self, path, title='', desc='', server_file_name=None):
self.path = path
self.title = title
self.desc = desc
self.server_file_name = server_file_name
def __repr__(self):
return '<{clazz}, path: {path}, title: {title}, desc: {desc}, server_file_name:{server_file_name}>' \
.format(clazz=self.__class__.__name__,
path=self.path,
title=self.title,
desc=self.desc,
server_file_name=self.server_file_name)
def get_key_old(sid=None, jsessionid=None):
"""
get public key, hash and session id for login.
Args:
sid: session id. only for captcha login.
jsessionid: j-session id. only for captcha login.
Returns:
hash: salt for password encryption.
pubkey: rsa public key for password encryption.
sid: session id.
"""
headers = {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': "application/json, text/javascript, */*; q=0.01"
}
post_data = {
'appkey': APPKEY,
'platform': "pc",
'ts': str(int(datetime.now().timestamp()))
}
post_data['sign'] = cipher.sign_dict(post_data, APPSECRET)
cookie = {}
if sid:
cookie['sid'] = sid
if jsessionid:
cookie['JSESSIONID'] = jsessionid
r = requests.post(
# "https://passport.bilibili.com/api/oauth2/getKey",
"https://passport.bilibili.com/x/passport-login/web/key",
headers=headers,
data=post_data,
cookies=cookie
)
print(r.content.decode())
r_data = r.json()['data']
if sid:
return r_data['hash'], r_data['key'], sid
return r_data['hash'], r_data['key'], r.cookies['sid']
def get_key():
headers = {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': "application/json, text/javascript, */*; q=0.01"
}
params_data = {
'appkey': LOGIN_APPKEY,
# 'ts': str(int(datetime.now().timestamp()))
}
params_data['sign'] = cipher.login_sign_dict_bin(params_data)
r = requests.get(
"https://passport.bilibili.com/x/passport-login/web/key",
headers=headers,
params=params_data
)
r_data = r.json()['data']
return r_data['hash'], r_data['key'], ''
def get_capcha(sid):
headers = {
'User-Agent': '',
'Accept-Encoding': 'gzip,deflate',
}
params = {
'appkey': APPKEY,
'platform': 'pc',
'ts': str(int(datetime.now().timestamp()))
}
params['sign'] = cipher.sign_dict(params, APPSECRET)
r = requests.get(
"https://passport.bilibili.com/captcha",
headers=headers,
params=params,
cookies={
'sid': sid
}
)
print(r.status_code)
capcha_data = r.content
return r.cookies['JSESSIONID'], capcha_data
def recognize_captcha(img: bytes):
img_base64 = str(base64.b64encode(img), encoding='utf-8')
r = requests.post(
url=CAPTCHA_RECOGNIZE_URL,
data={'image': img_base64}
)
return r.content.decode()
def login(username, password):
"""
bilibili login.
Args:
username: plain text username for bilibili.
password: plain text password for bilibili.
Returns:
code: login response code (0: success, -105: captcha error, ...).
access_token: token for further operation.
refresh_token: token for refresh access_token.
sid: session id.
mid: member id.
expires_in: access token expire time (30 days)
"""
hash, pubkey, sid = get_key()
encrypted_password = cipher.encrypt_login_password(password, hash, pubkey)
url_encoded_username = parse.quote_plus(username)
url_encoded_password = parse.quote_plus(encrypted_password)
post_data = {
'appkey': LOGIN_APPKEY,
'password': url_encoded_password,
'ts': str(int(datetime.now().timestamp())),
'username': url_encoded_username
}
post_data['sign'] = cipher.login_sign_dict_bin(post_data)
# avoid multiple url parse
post_data['username'] = username
post_data['password'] = encrypted_password
headers = {
'Connection': 'keep-alive',
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
'User-Agent': '',
'Accept-Encoding': 'gzip,deflate',
}
r = requests.post(
# "https://passport.bilibili.com/api/v3/oauth2/login",
"https://passport.bilibili.com/x/passport-login/oauth2/login",
headers=headers,
data=post_data,
)
response = r.json()
response_code = response['code']
if response_code == 0:
login_data = response['data']['token_info']
return response_code, login_data['access_token'], login_data['refresh_token'], sid, login_data['mid'], \
login_data["expires_in"]
elif response_code == -105: # captcha error, retry=5
retry_cnt = 5
while response_code == -105 and retry_cnt > 0:
response_code, access_token, refresh_token, sid, mid, expire_in = login_captcha(username, password, sid)
if response_code == 0:
return response_code, access_token, refresh_token, sid, mid, expire_in
retry_cnt -= 1
# other error code
return response_code, None, None, sid, None, None
def login_captcha(username, password, sid):
"""
bilibili login with captcha.
depend on captcha recognize service, please do not use this as first choice.
Args:
username: plain text username for bilibili.
password: plain text password for bilibili.
sid: session id
Returns:
code: login response code (0: success, -105: captcha error, ...).
access_token: token for further operation.
refresh_token: token for refresh access_token.
sid: session id.
mid: member id.
expires_in: access token expire time (30 days)
"""
jsessionid, captcha_img = get_capcha(sid)
captcha_str = recognize_captcha(captcha_img)
hash, pubkey, sid = get_key(sid, jsessionid)
encrypted_password = cipher.encrypt_login_password(password, hash, pubkey)
url_encoded_username = parse.quote_plus(username)
url_encoded_password = parse.quote_plus(encrypted_password)
post_data = {
'appkey': APPKEY,
'captcha': captcha_str,
'password': url_encoded_password,
'platform': "pc",
'ts': str(int(datetime.now().timestamp())),
'username': url_encoded_username
}
post_data['sign'] = cipher.sign_dict(post_data, APPSECRET)
# avoid multiple url parse
post_data['username'] = username
post_data['password'] = encrypted_password
post_data['captcha'] = captcha_str
headers = {
'Connection': 'keep-alive',
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
'User-Agent': '',
'Accept-Encoding': 'gzip,deflate',
}
r = requests.post(
"https://passport.bilibili.com/api/oauth2/login",
headers=headers,
data=post_data,
cookies={
'JSESSIONID': jsessionid,
'sid': sid
}
)
response = r.json()
if response['code'] == 0:
login_data = response['data']
return response['code'], login_data['access_token'], login_data['refresh_token'], sid, login_data['mid'], \
login_data["expires_in"]
else:
return response['code'], None, None, sid, None, None
def login_by_access_token(access_token):
"""
bilibili access token login.
Args:
access_token: Bilibili access token got by previous username/password login.
Returns:
sid: session id.
mid: member id.
expires_in: access token expire time
"""
headers = {
'Connection': 'keep-alive',
'Accept-Encoding': 'gzip,deflate',
'Host': 'passport.bilibili.com',
'User-Agent': '',
}
login_params = {
'appkey': APPKEY,
'access_token': access_token,
'platform': "pc",
'ts': str(int(datetime.now().timestamp())),
}
login_params['sign'] = cipher.sign_dict(login_params, APPSECRET)
r = requests.get(
url="https://passport.bilibili.com/api/oauth2/info",
headers=headers,
params=login_params
)
login_data = r.json()['data']
return r.cookies['sid'], login_data['mid'], login_data["expires_in"]
def upload_cover(access_token, sid, cover_file_path):
with open(cover_file_path, "rb") as f:
cover_pic = f.read()
headers = {
'Connection': 'keep-alive',
'Host': 'member.bilibili.com',
'Accept-Encoding': 'gzip,deflate',
'User-Agent': '',
}
params = {
"access_key": access_token,
}
params["sign"] = cipher.sign_dict(params, APPSECRET)
files = {
'file': ("cover.png", cover_pic, "Content-Type: image/png"),
}
r = requests.post(
"http://member.bilibili.com/x/vu/client/cover/up",
headers=headers,
params=params,
files=files,
cookies={
'sid': sid
},
verify=False,
)
return r.json()["data"]["url"]
def upload_chunk(upload_url, server_file_name, local_file_name, chunk_data, chunk_size, chunk_id, chunk_total_num):
"""
upload video chunk.
Args:
upload_url: upload url by pre_upload api.
server_file_name: file name on server by pre_upload api.
local_file_name: video file name in local fs.
chunk_data: binary data of video chunk.
chunk_size: default of ugc_assisstant is 2M.
chunk_id: chunk number.
chunk_total_num: total chunk number.
Returns:
True: upload chunk success.
False: upload chunk fail.
"""
print("chunk{}/{}".format(chunk_id, chunk_total_num))
print("filename: {}".format(local_file_name))
files = {
'version': (None, '2.0.0.1054'),
'filesize': (None, chunk_size),
'chunk': (None, chunk_id),
'chunks': (None, chunk_total_num),
'md5': (None, cipher.md5_bytes(chunk_data)),
'file': (local_file_name, chunk_data, 'application/octet-stream')
}
r = requests.post(
url=upload_url,
files=files,
cookies={
'PHPSESSID': server_file_name
},
)
print(r.status_code)
print(r.content)
if r.status_code == 200 and r.json()['OK'] == 1:
return True
else:
return False
def upload_video_part(access_token, sid, mid, video_part: VideoPart, max_retry=5, cb=None):
"""
upload a video file.
Args:
access_token: access token generated by login api.
sid: session id.
mid: member id.
video_part: local video file data.
max_retry: max retry number for each chunk.
cb: 回调
Returns:
status: success or fail.
server_file_name: server file name by pre_upload api.
"""
if cb is None:
cb = lambda f, c, t: None
if not isinstance(video_part, VideoPart):
return False
if video_part.server_file_name is not None:
return True
headers = {
'Connection': 'keep-alive',
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
'User-Agent': '',
'Accept-Encoding': 'gzip,deflate',
}
r = requests.get(
"http://member.bilibili.com/preupload?access_key={}&mid={}&profile=ugcfr%2Fpc3".format(access_token, mid),
headers=headers,
cookies={
'sid': sid
},
verify=False,
)
pre_upload_data = r.json()
upload_url = pre_upload_data['url']
complete_upload_url = pre_upload_data['complete']
server_file_name = pre_upload_data['filename']
local_file_name = video_part.path
file_size = os.path.getsize(local_file_name)
chunk_total_num = int(math.ceil(file_size / CHUNK_SIZE))
file_hash = hashlib.md5()
with open(local_file_name, 'rb') as f:
for chunk_id in range(0, chunk_total_num):
chunk_data = f.read(CHUNK_SIZE)
cb(video_part, chunk_id, chunk_total_num)
status = Retry(max_retry=max_retry, success_return_value=True).run(
upload_chunk,
upload_url,
server_file_name,
os.path.basename(local_file_name),
chunk_data,
CHUNK_SIZE,
chunk_id,
chunk_total_num
)
if not status:
return False
file_hash.update(chunk_data)
print(file_hash.hexdigest())
# complete upload
post_data = {
'chunks': chunk_total_num,
'filesize': file_size,
'md5': file_hash.hexdigest(),
'name': os.path.basename(local_file_name),
'version': '2.0.0.1054',
}
r = requests.post(
url=complete_upload_url,
data=post_data,
headers=headers,
)
print(r.status_code)
print(r.content)
video_part.server_file_name = server_file_name
return True
def upload(access_token,
sid,
mid,
parts,
copyright: int,
title: str,
tid: int,
tag: str,
desc: str,
source: str = '',
cover: str = '',
no_reprint: int = 0,
open_elec: int = 1,
max_retry: int = 5,
thread_pool_workers: int = 1):
"""
upload video.
Args:
access_token: oauth2 access token.
sid: session id.
mid: member id.
parts: VideoPart list.
copyright: 原创/转载.
title: 投稿标题.
tid: 分区id.
tag: 标签.
desc: 投稿简介.
source: 转载地址.
cover: 封面图片文件路径.
no_reprint: 可否转载.
open_elec: 充电.
max_retry: max retry time for each chunk.
thread_pool_workers: max upload threads.
Returns:
(aid, bvid)
aid: av号
bvid: bv号
"""
if not isinstance(parts, list):
parts = [parts]
status = True
with ThreadPoolExecutor(max_workers=thread_pool_workers) as tpe:
t_list = []
for video_part in parts:
print("upload {} added in pool".format(video_part.title))
t_obj = tpe.submit(upload_video_part, access_token, sid, mid, video_part, max_retry)
t_obj.video_part = video_part
t_list.append(t_obj)
for t_obj in as_completed(t_list):
status = status and t_obj.result()
print("video part {} finished, status: {}".format(t_obj.video_part.title, t_obj.result()))
if not status:
print("upload failed")
return None, None
# cover
if os.path.isfile(cover):
try:
cover = upload_cover(access_token, sid, cover)
except:
cover = ''
else:
cover = ''
# submit
headers = {
'Connection': 'keep-alive',
'Content-Type': 'application/json',
'User-Agent': '',
}
post_data = {
'build': 1054,
'copyright': copyright,
'cover': cover,
'desc': desc,
'no_reprint': no_reprint,
'open_elec': open_elec,
'source': source,
'tag': tag,
'tid': tid,
'title': title,
'videos': []
}
for video_part in parts:
post_data['videos'].append({
"desc": video_part.desc,
"filename": video_part.server_file_name,
"title": video_part.title
})
params = {
'access_key': access_token,
}
params['sign'] = cipher.sign_dict(params, APPSECRET)
r = requests.post(
url="http://member.bilibili.com/x/vu/client/add",
params=params,
headers=headers,
verify=False,
cookies={
'sid': sid
},
json=post_data,
)
print("submit")
print(r.status_code)
print(r.content.decode())
data = r.json()["data"]
return data["aid"], data["bvid"]
def get_post_data(access_token, sid, avid):
headers = {
'Connection': 'keep-alive',
'Host': 'member.bilibili.com',
'Accept-Encoding': 'gzip,deflate',
'User-Agent': '',
}
params = {
"access_key": access_token,
"aid": avid,
"build": "1054"
}
params["sign"] = cipher.sign_dict(params, APPSECRET)
r = requests.get(
url="http://member.bilibili.com/x/client/archive/view",
headers=headers,
params=params,
cookies={
'sid': sid
}
)
return r.json()["data"]
def edit_videos(
access_token,
sid,
mid,
avid=None,
bvid=None,
parts=None,
insert_index=None,
copyright=None,
title=None,
tid=None,
tag=None,
desc=None,
source=None,
cover=None,
no_reprint=None,
open_elec=None,
max_retry: int = 5,
thread_pool_workers: int = 1):
"""
insert videos into existed post.
Args:
access_token: oauth2 access token.
sid: session id.
mid: member id.
avid: av number,
bvid: bv string,
parts: VideoPart list.
insert_index: new video index.
copyright: 原创/转载.
title: 投稿标题.
tid: 分区id.
tag: 标签.
desc: 投稿简介.
source: 转载地址.
cover: cover url.
no_reprint: 可否转载.
open_elec: 充电.
max_retry: max retry time for each chunk.
thread_pool_workers: max upload threads.
Returns:
(aid, bvid)
aid: av号
bvid: bv号
"""
if not avid and not bvid:
print("please provide avid or bvid")
return None, None
if not avid:
avid = cipher.bv2av(bvid)
if not isinstance(parts, list):
parts = [parts]
if type(avid) is str:
avid = int(avid)
post_video_data = get_post_data(access_token, sid, avid)
status = True
with ThreadPoolExecutor(max_workers=thread_pool_workers) as tpe:
t_list = []
for video_part in parts:
print("upload {} added in pool".format(video_part.title))
t_obj = tpe.submit(upload_video_part, access_token, sid, mid, video_part, max_retry)
t_obj.video_part = video_part
t_list.append(t_obj)
for t_obj in as_completed(t_list):
status = status and t_obj.result()
print("video part {} finished, status: {}".format(t_obj.video_part.title, t_obj.result()))
if not status:
print("upload failed")
return None, None
headers = {
'Connection': 'keep-alive',
'Content-Type': 'application/json',
'User-Agent': '',
}
submit_data = {
'aid': avid,
'build': 1054,
'copyright': post_video_data["archive"]["copyright"],
'cover': post_video_data["archive"]["cover"],
'desc': post_video_data["archive"]["desc"],
'no_reprint': post_video_data["archive"]["no_reprint"],
'open_elec': post_video_data["archive_elec"]["state"], # open_elec not tested
'source': post_video_data["archive"]["source"],
'tag': post_video_data["archive"]["tag"],
'tid': post_video_data["archive"]["tid"],
'title': post_video_data["archive"]["title"],
'videos': post_video_data["videos"]
}
# cover
if os.path.isfile(cover):
try:
cover = upload_cover(access_token, sid, cover)
except:
cover = ''
else:
cover = ''
# edit archive data
if copyright:
submit_data["copyright"] = copyright
if title:
submit_data["title"] = title
if tid:
submit_data["tid"] = tid
if tag:
submit_data["tag"] = tag
if desc:
submit_data["desc"] = desc
if source:
submit_data["source"] = source
if cover:
submit_data["cover"] = cover
if no_reprint:
submit_data["no_reprint"] = no_reprint
if open_elec:
submit_data["open_elec"] = open_elec
if type(insert_index) is int:
for i, video_part in enumerate(parts):
submit_data['videos'].insert(insert_index + i, {
"desc": video_part.desc,
"filename": video_part.server_file_name,
"title": video_part.title
})
elif insert_index is None:
for video_part in parts:
submit_data['videos'].append({
"desc": video_part.desc,
"filename": video_part.server_file_name,
"title": video_part.title
})
else:
print("wrong insert index")
return None, None
params = {
'access_key': access_token,
}
params['sign'] = cipher.sign_dict(params, APPSECRET)
r = requests.post(
url="http://member.bilibili.com/x/vu/client/edit",
params=params,
headers=headers,
verify=False,
cookies={
'sid': sid
},
json=submit_data,
)
print("edit submit")
print(r.status_code)
print(r.content.decode())
data = r.json()["data"]
return data["aid"], data["bvid"]

View File

@ -0,0 +1 @@
from .cipher import *

View File

@ -0,0 +1,119 @@
import hashlib
import rsa
import base64
import subprocess
import platform
import os.path
def md5(data: str):
"""
generate md5 hash of utf-8 encoded string.
"""
return hashlib.md5(data.encode("utf-8")).hexdigest()
def md5_bytes(data: bytes):
"""
generate md5 hash of binary.
"""
return hashlib.md5(data).hexdigest()
def sign_str(data: str, app_secret: str):
"""
sign a string of request parameters
Args:
data: string of request parameters, must be sorted by key before input.
app_secret: a secret string coupled with app_key.
Returns:
A hash string. len=32
"""
return md5(data + app_secret)
def sign_dict(data: dict, app_secret: str):
"""
sign a dictionary of request parameters
Args:
data: dictionary of request parameters.
app_secret: a secret string coupled with app_key.
Returns:
A hash string. len=32
"""
data_str = []
keys = list(data.keys())
keys.sort()
for key in keys:
data_str.append("{}={}".format(key, data[key]))
data_str = "&".join(data_str)
data_str = data_str + app_secret
return md5(data_str)
def login_sign_dict_bin(data: dict):
data_str = []
keys = list(data.keys())
keys.sort()
for key in keys:
data_str.append("{}={}".format(key, data[key]))
data_str = "&".join(data_str)
package_directory = os.path.dirname(os.path.abspath(__file__))
if platform.system().lower() == 'windows':
print(data_str)
print(subprocess.Popen([os.path.join(package_directory, "sign.exe"), data_str], stdout=subprocess.PIPE).communicate()[0].decode().strip())
return subprocess.Popen([os.path.join(package_directory, "sign.exe"), data_str], stdout=subprocess.PIPE).communicate()[0].decode().strip()
if platform.system().lower() == 'linux':
return subprocess.Popen([os.path.join(package_directory, "sign.out"), data_str], stdout=subprocess.PIPE).communicate()[0].decode().strip()
raise Exception("Operating System is not supported.")
def encrypt_login_password(password, hash, pubkey):
"""
encrypt password for login api.
Args:
password: plain text of user password.
hash: hash provided by /api/oauth2/getKey.
pubkey: public key provided by /api/oauth2/getKey.
Returns:
An encrypted cipher of password.
"""
return base64.b64encode(rsa.encrypt(
(hash + password).encode('utf-8'),
rsa.PublicKey.load_pkcs1_openssl_pem(pubkey.encode()),
))
def av2bv(av: int):
table = 'fZodR9XQDSUm21yCkr6zBqiveYah8bt4xsWpHnJE7jL5VG3guMTKNPAwcF'
tr = {}
for i in range(58):
tr[table[i]] = i
s = [11, 10, 3, 8, 4, 6]
xor = 177451812
add = 8728348608
av = (av ^ xor) + add
r = list('BV1 4 1 7 ')
for i in range(6):
r[s[i]] = table[av // 58 ** i % 58]
return ''.join(r)
def bv2av(bv: str):
table = 'fZodR9XQDSUm21yCkr6zBqiveYah8bt4xsWpHnJE7jL5VG3guMTKNPAwcF'
tr = {}
for i in range(58):
tr[table[i]] = i
s = [11, 10, 3, 8, 4, 6]
xor = 177451812
add = 8728348608
r = 0
for i in range(6):
r += tr[bv[s[i]]] * 58 ** i
return (r - add) ^ xor

View File

@ -0,0 +1,18 @@
class Retry:
def __init__(self, max_retry, success_return_value):
self.max_retry = max_retry
self.success_return_value = success_return_value
def run(self, func, *args, **kwargs):
status = False
for i in range(0, self.max_retry):
try:
return_value = func(*args, **kwargs)
except Exception:
return_value = not self.success_return_value
if return_value == self.success_return_value:
status = True
break
return status

Binary file not shown.

Binary file not shown.

153
liveDownloader.py Normal file
View File

@ -0,0 +1,153 @@
import time
from datetime import datetime
import threading
import Common
import os
import requests
session = requests.session()
def download():
while Common.api.isLive and not Common.forceNotDownload:
if not Common.streamUrl:
Common.appendError("Download with No StreamUrl Specific")
break
path = datetime.strftime(datetime.now(), "%Y%m%d_%H%M.flv")
base_path = Common.config["path"]
if not os.path.isdir(base_path):
os.makedirs(base_path)
try:
p = session.get(Common.streamUrl, stream=True, timeout=3)
p.raise_for_status()
except Exception as e:
Common.appendError("Download >{}< with Exception [{}]".format(path,e.__str__()))
break
Common.api.initSave(os.path.join(base_path, path)+".xml")
Common.appendDownloadStatus("Download >{}< Start".format(path))
f = open(os.path.join(base_path, path), "wb")
_size = 0
try:
for T in p.iter_content(chunk_size=Common.config["c_s"]):
if Common.forceNotDownload:
Common.modifyLastDownloadStatus("Force Stop Download".format(path))
return
f.write(T)
_size += len(T)
Common.modifyLastDownloadStatus(
"Downloading >{}< @ {:.2f}%".format(path, 100.0 * _size / Common.config["p_s"]))
if _size > Common.config["p_s"] and not Common.config["dlO"]:
Common.modifyLastDownloadStatus("Download >{}< Exceed MaxSize".format(path))
break
Common.modifyLastDownloadStatus("Download >{}< Finished".format(path))
except Exception as e:
Common.appendError("Download >{}< With Exception {}".format(path, e.__str__()))
Common.api.updRoomInfo(True)
finally:
f.close()
if os.path.getsize(os.path.join(base_path, path)) < Common.config["i_s"]:
Common.modifyLastDownloadStatus("Downloaded File >{}< is too small, will ignore it".format(path))
else:
Common.encodeQueue.put(os.path.join(base_path, path))
Common.doUpdatePlaylist()
Common.api.updRoomInfo(True)
def encode():
Common.appendEncodeStatus("Encode Daemon Starting")
while True:
i = Common.encodeQueue.get()
Common.encodeVideo(i)
def upload():
date = datetime.strftime(datetime.now(), Common.config["sdf"])
Common.appendUploadStatus("Upload Daemon Starting")
i = Common.uploadQueue.get()
while True:
if i is True:
Common.publishVideo(date)
break
try:
Common.uploadVideo(i)
except Exception as e:
Common.appendError(e.__str__())
continue
finally:
time.sleep(90)
i = Common.uploadQueue.get()
Common.appendUploadStatus("Upload Daemon Quiting")
t = threading.Thread(target=download, args=())
ut = threading.Thread(target=upload, args=())
et = threading.Thread(target=encode, args=())
def awakeEncode():
global et
if et.is_alive():
return True
et = threading.Thread(target=encode, args=())
et.setDaemon(True)
et.start()
return False
def awakeDownload():
global t
if t.is_alive():
return True
t = threading.Thread(target=download, args=())
t.setDaemon(True)
t.start()
Common.api.updRoomInfo()
return False
def awakeUpload():
global ut
if ut.is_alive():
return True
ut = threading.Thread(target=upload, args=())
ut.setDaemon(True)
ut.start()
return False
def run():
Common.refreshDownloader()
if not Common.api.isValidUser:
Common.appendError("[{}]用户未找到".format(Common.api.name))
return
while True:
if Common.api.isLive and not Common.forceNotBroadcasting:
if not Common.forceNotDownload:
awakeDownload()
if not Common.forceNotUpload:
awakeUpload()
if not Common.forceNotEncode:
awakeEncode()
try:
Common.api.getDanmaku()
except Exception as e:
Common.appendError(e.__str__())
finally:
time.sleep(1)
else:
try:
Common.api.updRoomInfo()
except Exception as e:
Common.appendError(e.__str__())
Common.refreshDownloader()
if not Common.api.broadcaster:
Common.refreshDownloader()
if Common.forceStartEncodeThread:
awakeEncode()
Common.forceStartEncodeThread = False
if Common.forceStartUploadThread:
awakeUpload()
Common.forceStartUploadThread = False
if Common.doDelay():
Common.uploadQueue.put(True)
time.sleep(5)

12
requirements.txt Normal file
View File

@ -0,0 +1,12 @@
psutil>=5.9.0
certifi>=2020.4.5.1
chardet>=3.0.4
idna>=2.9
pyasn1>=0.4.8
requests>=2.23.0
rsa>=4.0
urllib3>=1.25.9
flask>=2.0.2
flask_cors>=3.0.10
protobuf>=3.19.3
python-dotenv>=0.19.2

25
static/device.js Normal file
View File

@ -0,0 +1,25 @@
function deviceUpdate(){
$.ajax(
"/stats/device",
{
success: function (res){
$("#memTotal").text(res.data.status.memTotal)
$("#memUsed").text(res.data.status.memUsed)
$("#memUsage").text(res.data.status.memUsage)
$("#diskTotal").text(res.data.status.diskTotal)
$("#diskUsed").text(res.data.status.diskUsed)
$("#diskUsage").text(res.data.status.diskUsage)
$("#cpu").text(res.data.status.cpu)
$("#memUsageP").val(res.data.status.memUsage)
$("#diskUsageP").val(res.data.status.diskUsage)
$("#cpuP").val(res.data.status.cpu)
$("#inSpeed").text(res.data.status.inSpeed)
$("#outSpeed").text(res.data.status.outSpeed)
$("#doCleanTime").text(res.data.status.doCleanTime)
}
}
)
}
deviceUpdate()
setInterval(deviceUpdate,2000)

59
static/index.js Normal file
View File

@ -0,0 +1,59 @@
function taskUpdate(){
$.ajax(
"/stats",
{
success: function (res){
$("#broadcaster").text(res.data.broadcast.broadcaster)
$("#isBroadcasting").text(res.data.broadcast.isBroadcasting)
$("#streamUrl").text(res.data.broadcast.streamUrl)
$("#delayTime").text(res.data.broadcast.delayTime)
$("#forceNotBroadcasting").text(res.data.config.forceNotBroadcasting)
$("#forceNotDownload").text(res.data.config.forceNotDownload)
$("#forceNotUpload").text(res.data.config.forceNotUpload)
$("#forceNotEncode").text(res.data.config.forceNotEncode)
$("#downloadOnly").text(res.data.config.downloadOnly)
$("#updateTime").text(res.data.broadcast.updateTime)
$("#encodeQueueSize").text(res.data.encodeQueueSize)
$("#uploadQueueSize").text(res.data.uploadQueueSize)
$("#download").html(function(){
var ret = ""
res.data.download.reverse().forEach(function(obj){
ret += "<tr><td class='time'>" + obj.datetime + "</td><td>" + obj.message + "</td></tr>"
})
return "<table>" + ret + "</table>"
})
$("#encode").html(function(){
var ret = ""
res.data.encode.reverse().forEach(function(obj){
ret += "<tr><td class='time'>" + obj.datetime + "</td><td>" + obj.message + "</td></tr>"
})
return "<table>" + ret + "</table>"
})
$("#upload").html(function(){
var ret = ""
res.data.upload.reverse().forEach(function(obj){
ret += "<tr><td class='time'>" + obj.datetime + "</td><td>" + obj.message + "</td></tr>"
})
return "<table>" + ret + "</table>"
})
$("#error").html(function(){
var ret = ""
res.data.error.reverse().forEach(function(obj){
ret += "<tr><td class='time'>" + obj.datetime + "</td><td>" + obj.message + "</td></tr>"
})
return "<table>" + ret + "</table>"
})
$("#operation").html(function(){
var ret = ""
res.data.operation.reverse().forEach(function(obj){
ret += "<tr><td class='time'>" + obj.datetime + "</td><td>" + obj.message + "</td></tr>"
})
return "<table>" + ret + "</table>"
})
}
}
)
}
taskUpdate()
setInterval(taskUpdate,8000)

30
templates/device.html Normal file
View File

@ -0,0 +1,30 @@
<h1>机器状态</h1>
<table>
<tr>
<td class='title'>CPU使用率</td>
<td><progress id="cpuP" max="100" value="0"></progress></td>
<td><span id="cpu"></span>%</td>
</tr>
<tr>
<td class='title'>内存使用率</td>
<td><progress id="memUsageP" max="100" value="0"></progress></td>
<td><span id="memUsed"></span>/<span id="memTotal"></span>(<span id="memUsage"></span>%)</td>
</tr>
<tr>
<td class='title'>磁盘使用率</td>
<td><progress id="diskUsageP" max="100" value="0"></progress></td>
<td><span id="diskUsed"></span>/<span id="diskTotal"></span>(<span id="diskUsage"></span>%)</td>
</tr>
<tr>
<td class='title'>网络速率</td>
<td><span id="inSpeed"></span>/s</td>
<td><span id="outSpeed"></span>/s</td>
</tr>
<tr>
<td class='title'>文件清理</td>
<td></td>
<td>@ <span id="doCleanTime"></span></td>
</tr>
</table>
<script src="/static/device.js"></script>

26
templates/files.html Normal file
View File

@ -0,0 +1,26 @@
<!DOCTYPE html>
<html lang="zh_CN">
<head>
<title>文件</title>
{% include 'head.html' %}
</head>
<body>
<div>
<h1>所有录像文件</h1>
<p>部分录像文件已转移至百度云,请在<a href="https://pan.baidu.com/s/1ECnwiHnsm-3dSXNJGWlR2g">这里</a>下载 提取码: ddxt</p>
<table>
<tr>
<td>文件名</td><td>文件大小</td><td>链接</td>
</tr>
{%for i in files %}
<tr>
<td>{{i.name}}</td><td>{{i.size}}</td><td><a href="/files/download/{{i.name}}">下载文件</a></td>
</tr>
{% endfor %}
</table>
<hr/>
<h3><a href="/">录播信息页</a></h3>
{% include 'device.html' %}
</div>
</body>
</html>

13
templates/head.html Normal file
View File

@ -0,0 +1,13 @@
<meta charset="UTF-8">
<script src="https://cdn.staticfile.org/jquery/3.3.1/jquery.min.js"></script>
<style>
td{
border: solid 1px lightgray;
}
.title{
width: 6em;
}
.time{
width: 10em;
}
</style>

86
templates/index.html Normal file
View File

@ -0,0 +1,86 @@
<!DOCTYPE html>
<html lang="zh_CN">
<head>
<title>录播</title>
{% include 'head.html' %}
</head>
<body>
<div>
<h1>基本信息</h1>
<table>
<tr>
<td>主播名</td>
<td><span id="broadcaster"></span></td>
</tr>
<tr>
<td>是否正在直播</td>
<td><span id="isBroadcasting"></span></td>
</tr>
<tr>
<td>直播视频流地址</td>
<td><span id="streamUrl"></span></td>
</tr>
<tr>
<td>信息更新时间</td>
<td><span id="updateTime"></span></td>
</tr>
<tr>
<td>延迟投稿时间</td>
<td><span id="delayTime"></span></td>
</tr>
</table>
<hr/>
<h1>特殊设置</h1>
<table>
<tr>
<td>是否设置强制认为不直播</td>
<td><span id="forceNotBroadcasting"></span></td>
</tr>
<tr>
<td>是否设置强制不下载</td>
<td><span id="forceNotDownload"></span></td>
</tr>
<tr>
<td>是否设置强制不上传</td>
<td><span id="forceNotUpload"></span></td>
</tr>
<tr>
<td>是否设置强制不转码</td>
<td><span id="forceNotEncode"></span></td>
</tr>
<tr>
<td>是否设置为仅下载(不上传不转码)</td>
<td><span id="downloadOnly"></span></td>
</tr>
</table>
<hr/>
<h1>当前状态</h1>
<table>
<tr>
<td class='title'>下载日志</td>
<td><span id="download"></span></td>
</tr>
<tr>
<td class='title'>转码日志<br>队列<span id="encodeQueueSize"></span></td>
<td><span id="encode"></span></td>
</tr>
<tr>
<td class='title'>上传日志<br>队列<span id="uploadQueueSize"></span></td>
<td><span id="upload"></span></td>
</tr>
<tr>
<td class='title'>错误日志</td>
<td><span id="error"></span></td>
</tr>
<tr>
<td class='title'>操作日志</td>
<td><span id="operation"></span></td>
</tr>
</table>
<hr/>
<h3><a href="/files/">所有录播文件</a></h3>
{% include 'device.html' %}
</div>
<script src="../static/index.js"></script>
</body>
</html>