├── .gitignore ├── AbemaTV ├── AbemaTV.py └── abematv_plu.py ├── AutoOperate.py ├── LICENSE ├── README.md ├── bilibiliProxy.py ├── config.json ├── login.py ├── main.py ├── myRequests.py ├── newInstall.sh ├── questInfo.py ├── requestHandler.py ├── scheduler.py ├── subprocessOp.py ├── utitls.py └── web ├── requests.js └── restream.html /.gitignore: -------------------------------------------------------------------------------- 1 | logfile.txt 2 | tmp_QuestList.json 3 | manualRestream.json 4 | jobs.sqlite 5 | 6 | # Byte-compiled / optimized / DLL files 7 | __pycache__/ 8 | *.py[cod] 9 | *$py.class 10 | 11 | # C extensions 12 | *.so 13 | 14 | # Distribution / packaging 15 | .Python 16 | build/ 17 | develop-eggs/ 18 | dist/ 19 | downloads/ 20 | eggs/ 21 | .eggs/ 22 | lib/ 23 | lib64/ 24 | parts/ 25 | sdist/ 26 | var/ 27 | wheels/ 28 | *.egg-info/ 29 | .installed.cfg 30 | *.egg 31 | MANIFEST 32 | 33 | # PyInstaller 34 | # Usually these files are written by a python script from a template 35 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 36 | *.manifest 37 | *.spec 38 | 39 | # Installer logs 40 | pip-log.txt 41 | pip-delete-this-directory.txt 42 | 43 | # Unit test / coverage reports 44 | htmlcov/ 45 | .tox/ 46 | .coverage 47 | .coverage.* 48 | .cache 49 | nosetests.xml 50 | coverage.xml 51 | *.cover 52 | .hypothesis/ 53 | .pytest_cache/ 54 | 55 | # Translations 56 | *.mo 57 | *.pot 58 | 59 | # Django stuff: 60 | *.log 61 | local_settings.py 62 | db.sqlite3 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # celery beat schedule file 88 | celerybeat-schedule 89 | 90 | # SageMath parsed files 91 | *.sage.py 92 | 93 | # Environments 94 | .env 95 | .venv 96 | env/ 97 | venv/ 98 | ENV/ 99 | env.bak/ 100 | venv.bak/ 101 | 102 | # Spyder project settings 103 | .spyderproject 104 | .spyproject 105 | 106 | # Rope project settings 107 | .ropeproject 108 | 109 | # mkdocs documentation 110 | /site 111 | 112 | # mypy 113 | .mypy_cache/ 114 | .dmypy.json 115 | dmypy.json 116 | 117 | #PyCharm 118 | .idea 119 | -------------------------------------------------------------------------------- /AbemaTV/AbemaTV.py: -------------------------------------------------------------------------------- 1 | from time import sleep 2 | import requests 3 | import re 4 | import subprocess 5 | import threading 6 | import sys 7 | import os 8 | 9 | K_CURRENT_PATH = os.path.dirname(os.path.abspath(__file__)) 10 | 11 | K_MAIN_M3U8 = "Main.m3u8" 12 | K_SUB_M3U8 = "Sub.m3u8" 13 | 14 | K_CHANNEL_NAME = "ultra-games" 15 | 16 | _g_IsUsingMainM3u8 = True 17 | _g_split_mark = "#EXTM3U" 18 | 19 | def runFuncAsyncThread(target_func, args): 20 | t = threading.Thread(target=target_func, args=args) 21 | t.start() 22 | 23 | def refreshM3u8(channel_name, uri_path, is_run_forever=True): 24 | global _g_IsUsingMainM3u8 25 | global _g_split_mark 26 | while True: 27 | pl = requests.get("https://linear-abematv.akamaized.net/channel/{}/1080/playlist.m3u8".format(channel_name), timeout=20).text 28 | ticket_list = re.findall(r"abematv-license://(.*)", pl) 29 | if len(ticket_list) >=1: 30 | uri_path = "{}?ticket={}".format(uri_path, ticket_list[0]) 31 | cur_pl = re.sub('URI=.*?\,', 'URI=\"{}\",'.format(uri_path), pl) 32 | else: 33 | cur_pl = pl 34 | next_pl = None 35 | 36 | tmp_cur_mark = None 37 | tmp_list = cur_pl.partition('#EXT-X-DISCONTINUITY\n') 38 | if tmp_list[2] != '': # if has next m3u8 39 | # set the split mark as the *.ts name 40 | tmp_cur_mark = tmp_list[0].partition('#EXT-X-DISCONTINUITY\n')[0].split('\n')[-2] 41 | cur_pl = tmp_list[0] + '#EXT-X-ENDLIST' # make current m3u8 end playing the old list 42 | next_pl = '#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:5\n' + tmp_list[2] # make the new list 43 | else: 44 | tmp_cur_mark = "#EXTM3U" 45 | 46 | if _g_split_mark not in cur_pl: 47 | _g_IsUsingMainM3u8 = False if _g_IsUsingMainM3u8 else True 48 | 49 | if tmp_cur_mark != _g_split_mark: 50 | _g_split_mark = tmp_cur_mark 51 | 52 | 53 | if _g_IsUsingMainM3u8: 54 | curFile = K_MAIN_M3U8 55 | nextFile = K_SUB_M3U8 56 | else: 57 | curFile = K_SUB_M3U8 58 | nextFile = K_MAIN_M3U8 59 | 60 | with open(curFile, "w") as f: 61 | f.write(cur_pl) 62 | print('-CURRENT-{} m3u8:\n{}\n'.format(curFile, cur_pl)) 63 | 64 | if next_pl: # write the next file 65 | with open(nextFile, "w") as f: 66 | f.write(next_pl) 67 | print('-NEXT-{} m3u8:\n{}\n'.format(nextFile, next_pl)) 68 | 69 | if is_run_forever == False: 70 | return cur_pl 71 | 72 | sleep(10) #the m3u8 has 4 segments, it can hold 20 secounds, Default is updated every 5 secounds 73 | 74 | def runCMD(cmd): 75 | p = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) 76 | pid = p.pid 77 | out, err = p.communicate() 78 | errcode = p.returncode 79 | return pid, out, err, errcode 80 | 81 | def startFFMPEG(cmd, m3u8): 82 | m3u8_file = m3u8 83 | while True: 84 | print("====\nUsing the m3u8 File. -->{}".format(m3u8_file)) 85 | pid, out, err, errcode = runCMD(cmd) 86 | if errcode == 0: 87 | # filp the File 88 | # m3u8_file = K_MAIN_M3U8 if m3u8_file == K_SUB_M3U8 else K_SUB_M3U8 89 | print("----\nChanging the m3u8 File. \nChanging File To===>{}".format(m3u8_file)) 90 | else: 91 | print("CMD RUN END with PID:{}\nOUT: {}\nERR: {}\nERRCODE: {}".format(pid, out, err, errcode)) 92 | print('RETRYING___________THIS: startFFMPEG') 93 | sleep(5) 94 | 95 | 96 | def restreamFromYoutube(youtubeURL, rtmp_link): 97 | sleep(10) #wait for the restream 98 | while True: 99 | pid, out, err, errcode = runCMD('youtube-dl -g {}'.format(youtubeURL)) 100 | out = out.decode('utf-8') if isinstance(out, (bytes, bytearray)) else out 101 | if '.m3u8' in out: 102 | youtubeM3u8 = out.strip() 103 | youtubeReCmd = 'ffmpeg -loglevel error \ 104 | -re \ 105 | -i "{}" \ 106 | -vcodec copy -acodec aac -strict -2 -ac 2 -bsf:a aac_adtstoasc \ 107 | -f flv "{}"'.format(youtubeM3u8, rtmp_link) 108 | startFFMPEG(youtubeReCmd, youtubeM3u8) 109 | 110 | sleep(10) 111 | 112 | 113 | from abematv_plu import AbemaTV 114 | g_ab = AbemaTV() 115 | g_ab.init_usertoken() 116 | 117 | from http.server import HTTPServer, BaseHTTPRequestHandler 118 | from urllib.parse import urlsplit,parse_qs 119 | class MyHandler(BaseHTTPRequestHandler): 120 | 121 | def do_GET(self): 122 | global g_ab 123 | body = "" 124 | if self.path == '/playlist.m3u8': 125 | m3u8_str = refreshM3u8(K_CHANNEL_NAME, 'myfile.dat', False) 126 | body = m3u8_str.encode('utf-8') 127 | elif '/myfile.dat?' in self.path: 128 | params = parse_qs(urlsplit(self.path).query) 129 | ticket_list = params.get('ticket', None) 130 | if ticket_list and len(ticket_list)>0: 131 | ticket = str.encode(ticket_list[0]) 132 | body = g_ab.get_videokey_from_ticket(ticket) 133 | print("CurrentKey:" + body) 134 | self.send_response(200) 135 | self.send_header('Content-type', 'application/x-mpegURL') 136 | self.send_header('Content-length', len(body)) 137 | self.end_headers() 138 | self.wfile.write(body) 139 | 140 | 141 | if __name__ == '__main__': 142 | if os.path.exists(K_MAIN_M3U8): 143 | os.remove(K_MAIN_M3U8) 144 | if os.path.exists(K_SUB_M3U8): 145 | os.remove(K_SUB_M3U8) 146 | 147 | rtmp_link = 'test.mp4' 148 | if len(sys.argv) >= 2: 149 | K_CHANNEL_NAME = sys.argv[1] 150 | rtmp_link = sys.argv[2] 151 | 152 | is_restream = False 153 | if len(sys.argv) >= 4: 154 | youtubeURL = sys.argv[3] 155 | bilibili_rtmp = sys.argv[4] 156 | is_restream = True 157 | 158 | print('RUNNING with channel:{} to {}'.format(K_CHANNEL_NAME, rtmp_link)) 159 | 160 | abematvM3u8 = 'http://localhost:10800/playlist.m3u8' 161 | abematvCmd = 'ffmpeg -loglevel error \ 162 | -re \ 163 | -protocol_whitelist file,http,https,tcp,tls,crypto -allowed_extensions ALL \ 164 | -i "{}" \ 165 | -vcodec copy -acodec aac -strict -2 -ac 2 -bsf:a aac_adtstoasc \ 166 | -f flv "{}"'.format(abematvM3u8, rtmp_link) 167 | runFuncAsyncThread(startFFMPEG, (abematvCmd, abematvM3u8)) 168 | 169 | if is_restream: 170 | # run the restream 171 | runFuncAsyncThread(restreamFromYoutube, (youtubeURL, bilibili_rtmp)) 172 | 173 | 174 | httpd = HTTPServer(('localhost', 10800), MyHandler) 175 | httpd.serve_forever() 176 | -------------------------------------------------------------------------------- /AbemaTV/abematv_plu.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import hmac 3 | import re 4 | import struct 5 | import time 6 | import uuid 7 | 8 | from base64 import urlsafe_b64encode 9 | from binascii import unhexlify 10 | 11 | from Crypto.Cipher import AES 12 | 13 | import requests 14 | # from requests import Response 15 | # from requests.adapters import BaseAdapter 16 | 17 | 18 | UA_CHROME = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) " 19 | "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/68.0.3440.75 Safari/537.36") 20 | 21 | 22 | class AbemaTVLicenseAdapter(): 23 | ''' 24 | Handling abematv-license:// protocol to get real video key_data. 25 | ''' 26 | 27 | STRTABLE = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" 28 | 29 | HKEY = b"3AF0298C219469522A313570E8583005A642E73EDD58E3EA2FB7339D3DF1597E" 30 | 31 | _MEDIATOKEN_API = "https://api.abema.io/v1/media/token" 32 | 33 | _LICENSE_API = "https://license.abema.io/abematv-hls" 34 | 35 | # _MEDIATOKEN_SCHEMA = validate.Schema({u"token": validate.text}) 36 | # 37 | # _LICENSE_SCHEMA = validate.Schema({u"k": validate.text, 38 | # u"cid": validate.text}) 39 | 40 | def __init__(self, session): 41 | self._session = session 42 | self.ticketDict = {} 43 | 44 | def init_user(self, deviceid, usertoken): 45 | self.deviceid = deviceid 46 | self.usertoken = usertoken 47 | 48 | def _get_videokey_from_ticket(self, ticket): 49 | ticket = ticket.decode('utf-8') if isinstance(ticket, (bytes, bytearray)) else ticket 50 | params = { 51 | "osName": "android", 52 | "osVersion": "6.0.1", 53 | "osLang": "ja_JP", 54 | "osTimezone": "Asia/Tokyo", 55 | "appId": "tv.abema", 56 | "appVersion": "3.27.1" 57 | } 58 | auth_header = {"Authorization": "Bearer " + self.usertoken} 59 | res = self._session.get(self._MEDIATOKEN_API, params=params, 60 | headers=auth_header) 61 | jsonres = res.json() 62 | mediatoken = jsonres['token'] 63 | 64 | res = self._session.post(self._LICENSE_API, 65 | params={"t": mediatoken}, 66 | json={"kv": "a", "lt": ticket}) 67 | jsonres = res.json() 68 | cid = jsonres['cid'] 69 | k = jsonres['k'] 70 | 71 | 72 | res = sum([self.STRTABLE.find(k[i]) * (58 ** (len(k) - 1 - i)) 73 | for i in range(len(k))]) 74 | encvideokey = struct.pack('>QQ', res >> 64, res & 0xffffffffffffffff) 75 | 76 | # HKEY: 77 | # RC4KEY = unhexlify('DB98A8E7CECA3424D975280F90BD03EE') 78 | # RC4DATA = unhexlify(b'D4B718BBBA9CFB7D0192A58F9E2D146A' 79 | # b'FC5DB29E4352DE05FC4CF2C1005804BB') 80 | # rc4 = ARC4.new(RC4KEY) 81 | # HKEY = rc4.decrypt(RC4DATA) 82 | h = hmac.new(unhexlify(self.HKEY), 83 | (cid + self.deviceid).encode("utf-8"), 84 | digestmod=hashlib.sha256) 85 | enckey = h.digest() 86 | 87 | aes = AES.new(enckey, AES.MODE_ECB) 88 | rawvideokey = aes.decrypt(encvideokey) 89 | 90 | return rawvideokey 91 | 92 | def get_videokey_from_ticket(self, ticket): 93 | ret_videokey = self.ticketDict.get(ticket, None) 94 | if ret_videokey: 95 | return ret_videokey 96 | else: 97 | # cache the key 98 | ret_videokey = self._get_videokey_from_ticket(ticket) 99 | self.ticketDict[ticket] = ret_videokey 100 | return ret_videokey 101 | 102 | 103 | class AbemaTV(): 104 | ''' 105 | Abema.tv https://abema.tv/ 106 | Note: Streams are geo-restricted to Japan 107 | 108 | ''' 109 | _url_re = re.compile(r"""https://abema\.tv/( 110 | now-on-air/(?P[^\?]+) 111 | | 112 | video/episode/(?P[^\?]+) 113 | | 114 | channels/.+?/slots/(?P[^\?]+) 115 | )""", re.VERBOSE) 116 | 117 | _CHANNEL = "https://api.abema.io/v1/channels" 118 | 119 | _USER_API = "https://api.abema.io/v1/users" 120 | 121 | _PRGM_API = "https://api.abema.io/v1/video/programs/{0}" 122 | 123 | _SLOTS_API = "https://api.abema.io/v1/media/slots/{0}" 124 | 125 | _PRGM3U8 = "https://vod-abematv.akamaized.net/program/{0}/playlist.m3u8" 126 | 127 | _SLOTM3U8 = "https://vod-abematv.akamaized.net/slot/{0}/playlist.m3u8" 128 | 129 | SECRETKEY = (b"v+Gjs=25Aw5erR!J8ZuvRrCx*rGswhB&qdHd_SYerEWdU&a?3DzN9B" 130 | b"Rbp5KwY4hEmcj5#fykMjJ=AuWz5GSMY-d@H7DMEh3M@9n2G552Us$$" 131 | b"k9cD=3TxwWe86!x#Zyhe") 132 | 133 | # _USER_SCHEMA = validate.Schema({u"profile": {u"userId": validate.text}, 134 | # u"token": validate.text}) 135 | # 136 | # _CHANNEL_SCHEMA = validate.Schema({u"channels": [{u"id": validate.text, 137 | # "name": validate.text, 138 | # "playback": {validate.optional(u"dash"): 139 | # validate.text, 140 | # u"hls": validate.text}}]}) 141 | # 142 | # _PRGM_SCHEMA = validate.Schema({u"label": {validate.optional(u"free"): bool 143 | # }}) 144 | # 145 | # _SLOT_SCHEMA = validate.Schema({u"slot": {u"flags": { 146 | # validate.optional("timeshiftFree"): bool}}} 147 | # ) 148 | 149 | @classmethod 150 | def can_handle_url(cls, url): 151 | return cls._url_re.match(url) is not None 152 | 153 | def __init__(self): 154 | self.session = requests.Session() 155 | self.session.headers.update({'User-Agent': UA_CHROME}) 156 | self.aba = AbemaTVLicenseAdapter(self.session) 157 | 158 | def _generate_applicationkeysecret(self, deviceid): 159 | deviceid = deviceid.encode("utf-8") # for python3 160 | # plus 1 hour and drop minute and secs 161 | # for python3 : floor division 162 | ts_1hour = (int(time.time()) + 60 * 60) // 3600 * 3600 163 | time_struct = time.gmtime(ts_1hour) 164 | ts_1hour_str = str(ts_1hour).encode("utf-8") 165 | 166 | h = hmac.new(self.SECRETKEY, digestmod=hashlib.sha256) 167 | h.update(self.SECRETKEY) 168 | tmp = h.digest() 169 | for i in range(time_struct.tm_mon): 170 | h = hmac.new(self.SECRETKEY, digestmod=hashlib.sha256) 171 | h.update(tmp) 172 | tmp = h.digest() 173 | h = hmac.new(self.SECRETKEY, digestmod=hashlib.sha256) 174 | h.update(urlsafe_b64encode(tmp).rstrip(b"=") + deviceid) 175 | tmp = h.digest() 176 | for i in range(time_struct.tm_mday % 5): 177 | h = hmac.new(self.SECRETKEY, digestmod=hashlib.sha256) 178 | h.update(tmp) 179 | tmp = h.digest() 180 | 181 | h = hmac.new(self.SECRETKEY, digestmod=hashlib.sha256) 182 | h.update(urlsafe_b64encode(tmp).rstrip(b"=") + ts_1hour_str) 183 | tmp = h.digest() 184 | 185 | for i in range(time_struct.tm_hour % 5): # utc hour 186 | h = hmac.new(self.SECRETKEY, digestmod=hashlib.sha256) 187 | h.update(tmp) 188 | tmp = h.digest() 189 | 190 | return urlsafe_b64encode(tmp).rstrip(b"=").decode("utf-8") 191 | 192 | 193 | def init_usertoken(self): 194 | deviceid = str(uuid.uuid4()) 195 | appkeysecret = self._generate_applicationkeysecret(deviceid) 196 | json_data = {"deviceId": deviceid, 197 | "applicationKeySecret": appkeysecret} 198 | res = self.session.post(self._USER_API, json=json_data) 199 | jsonres = res.json() 200 | self.usertoken = jsonres['token'] # for authorzation 201 | self.aba.init_user(deviceid, self.usertoken) 202 | 203 | def get_videokey_from_ticket(self, ticket): 204 | return self.aba.get_videokey_from_ticket(ticket) 205 | -------------------------------------------------------------------------------- /AutoOperate.py: -------------------------------------------------------------------------------- 1 | import utitls 2 | import time 3 | import traceback 4 | import os 5 | import signal 6 | 7 | from login import login 8 | from bilibiliProxy import BilibiliProxy 9 | from subprocessOp import _forwardStream_sync, resolveStreamToM3u8, async_forwardStream 10 | import questInfo 11 | from myRequests import subscribe, getUpcomingLiveVideos, getYoutubeLiveStreamInfo 12 | import scheduler 13 | 14 | def getBilibiliProxy(subObj): 15 | curSub = subObj 16 | curBiliAccCookie = curSub.get('bilibili_cookiesStr', "") 17 | b = BilibiliProxy(curBiliAccCookie) 18 | if b.getAccInfo() == None: 19 | #relogin 20 | if curSub['login_type'] == 'account': 21 | tmp_username, tmp_password = curSub.get('username'), curSub.get('password') 22 | if tmp_username and tmp_password: 23 | curSub['bilibili_cookiesStr'] = login(tmp_username, tmp_password) 24 | utitls.setSubInfoWithKey('username', tmp_username, curSub) 25 | time.sleep(1) 26 | return getBilibiliProxy(subObj) #retry the StartLive. TODO Maybe limit the retry time? 27 | else: 28 | return b 29 | 30 | def bilibiliStartLive(subscribe_obj, room_title, area_id=None): 31 | curSub = subscribe_obj 32 | 33 | tmp_area_id = area_id 34 | if tmp_area_id == None: 35 | tmp_area_id = curSub.get('bilibili_areaid', '199') 36 | 37 | b = getBilibiliProxy(curSub) 38 | 39 | t_room_id = b.getLiveRoomId() 40 | t_cur_blive_url = 'https://live.bilibili.com/' + t_room_id 41 | curSub['cur_blive_url'] = t_cur_blive_url 42 | # b.stopLive(t_room_id) #Just don't care the Live status, JUST STARTLIVE 43 | t_b_title = curSub.get('change_b_title') 44 | if t_b_title: 45 | b.updateRoomTitle(t_room_id, t_b_title) 46 | rtmp_link = b.startLive(t_room_id, tmp_area_id) 47 | 48 | if curSub.get('auto_send_dynamic') and rtmp_link and questInfo._getObjWithRTMPLink(rtmp_link) is None: 49 | if curSub.get('dynamic_template'): 50 | b.send_dynamic((curSub['dynamic_template']).replace('${roomUrl}', t_cur_blive_url)) 51 | else: 52 | b.send_dynamic('转播开始了哦~') 53 | return b, t_room_id, rtmp_link 54 | 55 | __g_try_bili_quest_list = [] 56 | def Async_forwardToBilibili(subscribe_obj, input_link, room_title='Testing Title', area_id=None, isSubscribeQuest=True): 57 | utitls.runFuncAsyncThread(_forwardToBilibili_Sync, (subscribe_obj, input_link, room_title, area_id, isSubscribeQuest)) 58 | def _forwardToBilibili_Sync(subscribe_obj, input_link, room_title, area_id=None, isSubscribeQuest=True): 59 | global __g_try_bili_quest_list 60 | utitls.myLogger('CURRENT Async_forwardToBilibili:\n{}'.format(__g_try_bili_quest_list)) 61 | 62 | input_quest = "{}_{}".format(subscribe_obj.get('mark', ""), input_link) 63 | if input_quest in __g_try_bili_quest_list: 64 | utitls.myLogger('current input quest is already RUNNING:\n{}'.format(input_quest)) 65 | return 66 | 67 | __g_try_bili_quest_list.append(input_quest) 68 | utitls.myLogger('APPEND QUEST Async_forwardToBilibili:\n{}'.format(input_quest)) 69 | resolveURLOK = False 70 | 71 | if isSubscribeQuest: 72 | tmp_retryTime = 60 * 10 #retry 10 hours, Some youtuber will startLive before few hours 73 | else: 74 | tmp_retryTime = 3 # if not subscribe quest, just try 3 minutes 75 | while tmp_retryTime > 0: 76 | if 'youtube.com/' in input_link or 'youtu.be/' in input_link: 77 | tmp_is_log = (tmp_retryTime % 60 == 0) # log every 60 minus 78 | m3u8Link, title, err, errcode = resolveStreamToM3u8(input_link, tmp_is_log) 79 | if errcode == 999: 80 | # this is just a video upload, so just finish it 81 | __g_try_bili_quest_list.remove(input_quest) 82 | utitls.myLogger('_forwardToBilibili_Sync LOG: This is not a Live Videos:' + input_link) 83 | return 84 | elif errcode == 0: 85 | # input_link = m3u8Link #just to check is can use, _forwardStream_sync will access the title and questInfo 86 | resolveURLOK = True 87 | break 88 | else: 89 | tmp_retryTime -= 1 90 | elif utitls.checkIsSupportForwardLink(input_link): 91 | resolveURLOK = True 92 | break 93 | else: 94 | if isSubscribeQuest: 95 | utitls.myLogger('_forwardToBilibili_Sync LOG: Unsupport ForwardLink:' + input_link) 96 | __g_try_bili_quest_list.remove(input_quest) 97 | return 98 | else: 99 | resolveURLOK = True # if it's not subscribeQuest, just start living 100 | break 101 | 102 | if resolveURLOK: 103 | tmp_acc_mark = subscribe_obj.get('mark', None) 104 | if tmp_acc_mark: 105 | quest = questInfo._getObjWithAccMark(tmp_acc_mark) 106 | if quest == None: 107 | b, t_room_id, rtmp_link = bilibiliStartLive(subscribe_obj, room_title, area_id) 108 | # force stream 109 | _forwardStream_sync(input_link, rtmp_link, isSubscribeQuest, subscribe_obj) 110 | else: 111 | utitls.myLogger("THIS ACC IS CURRENTLY STREAMING :{}, SKIP THE QUEST".format(tmp_acc_mark)) 112 | 113 | if input_quest in __g_try_bili_quest_list: 114 | utitls.myLogger('REMOVE QUEST Async_forwardToBilibili:\n{}'.format(input_quest)) 115 | __g_try_bili_quest_list.remove(input_quest) 116 | 117 | 118 | 119 | def Async_subscribeTheList(): 120 | utitls.runFuncAsyncThread(subscribeTheList_sync, ()) 121 | def subscribeTheList_sync(): 122 | time.sleep(10) #wait the server start preparing 123 | while True: 124 | subscribeList = utitls.configJson().get('subscribeList', []) 125 | ip = utitls.configJson().get('serverIP') 126 | port = utitls.configJson().get('serverPort') 127 | for item in subscribeList: 128 | tmp_subscribeId_list = item.get('youtubeChannelId', "").split(',') 129 | for tmp_subscribeId in tmp_subscribeId_list: 130 | if tmp_subscribeId != "": 131 | tmp_callback_url = 'http://{}:{}/subscribe'.format(ip, port) 132 | subscribe(tmp_callback_url, tmp_subscribeId) 133 | time.sleep(3600 * 24 * 4) #update the subscribe every 4 Days 134 | 135 | def clearOldQuests(): 136 | questInfo.initQuestList() 137 | 138 | def restartOldQuests(): 139 | time.sleep(3) #wait the server start preparing 140 | for quest in questInfo._getQuestList(): 141 | tmp_pid = quest.get('pid') 142 | if tmp_pid: 143 | try: 144 | os.kill(tmp_pid, 0) #just check is the pid Running 145 | except OSError: 146 | # if the pid was killed 147 | rtmp_link = quest.get('rtmpLink') 148 | questInfo.updateQuestInfo('isRestart', True, rtmp_link) 149 | async_forwardStream( 150 | quest.get('forwardLinkOrign'), 151 | rtmp_link, 152 | quest.get('isSubscribeQuest') 153 | ) 154 | 155 | def perparingAllComingVideos(): 156 | utitls.runFuncAsyncThread(perparingAllComingVideos_sync, ()) 157 | def perparingAllComingVideos_sync(): 158 | time.sleep(2) #wait the server start preparing 159 | subscribeList = utitls.configJson().get('subscribeList', []) 160 | for subObj in subscribeList: 161 | tmp_subscribeId_list = subObj.get('youtubeChannelId', "").split(',') 162 | for tmp_subscribeId in tmp_subscribeId_list: 163 | if tmp_subscribeId != "": 164 | videoIds = getUpcomingLiveVideos(tmp_subscribeId) 165 | for vid in videoIds: 166 | item = getYoutubeLiveStreamInfo(vid) 167 | if item: 168 | liveStreamingDetailsDict = item.get('liveStreamingDetails', None) 169 | if liveStreamingDetailsDict: 170 | tmp_is_live = liveStreamingDetailsDict.get('concurrentViewers', None) 171 | tmp_actual_start_time = liveStreamingDetailsDict.get('actualStartTime', None) 172 | tmp_scheduled_start_time = liveStreamingDetailsDict.get('scheduledStartTime', None) 173 | tmp_is_end = liveStreamingDetailsDict.get('actualEndTime', None) 174 | 175 | if tmp_scheduled_start_time and tmp_is_end == None and tmp_is_live == None and tmp_actual_start_time == None: 176 | tmp_acc_mark = subObj.get('mark', "") 177 | tmp_area_id = subObj.get('bilibili_areaid', '199') 178 | job_id = 'Acc:{},VideoID:{}'.format(tmp_acc_mark, vid) 179 | 180 | snippet = item.get('snippet', {}) 181 | tmp_title = snippet.get('title', "") 182 | tmp_live_link = 'https://www.youtube.com/watch?v={}'.format(vid) 183 | scheduler.add_date_job(tmp_scheduled_start_time, job_id, Async_forwardToBilibili, 184 | (subObj, tmp_live_link, tmp_title, tmp_area_id) 185 | ) 186 | 187 | def preparingAllAccountsCookies(): 188 | utitls.runFuncAsyncThread(preparingAllAccountsCookies_sync, ()) 189 | def preparingAllAccountsCookies_sync(): 190 | time.sleep(2) #wait the server start preparing 191 | sub_list = utitls.configJson().get('subscribeList', []) 192 | for curSub in sub_list: 193 | if curSub.get('login_type', "") == 'account' and curSub.get('bilibili_cookiesStr', "") == "": 194 | tmp_username, tmp_password = curSub.get('username'), curSub.get('password') 195 | if tmp_username and tmp_password: 196 | curSub['bilibili_cookiesStr'] = login(tmp_username, tmp_password) 197 | utitls.setSubInfoWithKey('username', tmp_username, curSub) 198 | time.sleep(5) # wait for the last browser memory release 199 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AutoYtB 2 | 订阅youtuber, 有视频时自动转播到对应的b站账号 3 | 4 | 感谢autolive的参考 : https://github.com/7rikka/autoLive 5 | 6 | ## 一键安装,适合完全新手,而且服务器也没有别的其它设置的(安装完毕后会重启机器,请注意) 7 | ``` 8 | wget https://raw.githubusercontent.com/HalfMAI/AutoYtB/master/newInstall.sh && chmod +x newInstall.sh && bash newInstall.sh 9 | ``` 10 | 如果服务器有别的设置的话建议参考下列各个软件进行选择性安装 11 | 12 | ## 软件依赖和环境安装 13 | #### streamlink, ffmpeg, python3 14 | #### 如果使用account登录模式, 还需要安装chrome与chromedriver或firefox与firefoxdriver 15 | 16 | ffmpeg安装,因为各系统不同安装方法也不同这里只提供vultr centos7的安装方法 17 | 这里安装的是最新版本的由https://www.johnvansickle.com/ffmpeg/ 编译的ffmpeg版本 18 | ``` 19 | sudo yum install epel-release -y 20 | sudo yum update -y 21 | shutdown -r now 22 | 23 | sudo rpm --import http://li.nux.ro/download/nux/RPM-GPG-KEY-nux.ro 24 | sudo rpm -Uvh http://li.nux.ro/download/nux/dextop/el7/x86_64/nux-dextop-release-0-5.el7.nux.noarch.rpm 25 | 26 | wget https://raw.githubusercontent.com/Sporesirius/ffmpeg-install/master/ffmpeg-install 27 | chmod a+x ffmpeg-install 28 | ./ffmpeg-install --install release 29 | rm -f ffmpeg-install 30 | ``` 31 | 32 | python3安装,这里安装的是3.7独立安装,运行时调用的是python3.7而不是python3。 33 | 如果系统没有 wget 请先运行 34 | ``` 35 | yum install -y wget 36 | ``` 37 | 然后再运行下面的 38 | ``` 39 | yum install -y gcc openssl-devel bzip2-devel libffi libffi-devel sqlite-devel 40 | cd /usr/src 41 | wget https://www.python.org/ftp/python/3.7.0/Python-3.7.0.tgz 42 | tar xzf Python-3.7.0.tgz 43 | cd Python-3.7.0 44 | ./configure --enable-optimizations 45 | make altinstall 46 | rm -f Python-3.7.0 47 | ``` 48 | 49 | 50 | ### chrome与chromedriver安装 51 | 下面采用了一键脚本安装chrome,喜欢自己动手的可以参照下面教程自己添加源安装 52 | https://intoli.com/blog/installing-google-chrome-on-centos/ 53 | ``` 54 | curl https://intoli.com/install-google-chrome.sh | bash 55 | ``` 56 | 安装chromedriver,chromedriver的版本要和你安装的chrome对应,具体对应版本请查看(例子中使用的是2.43) 57 | http://chromedriver.chromium.org/downloads 58 | ``` 59 | wget -N https://chromedriver.storage.googleapis.com/2.43/chromedriver_linux64.zip 60 | unzip chromedriver_linux64.zip 61 | chmod +x chromedriver 62 | mv chromedriver /usr/bin/ 63 | rm -f chromedriver_linux64.zip 64 | ``` 65 | 66 | 67 | streamlink安装,如果系统没有pip,请在安装python3.7后再更改为pip3.7 68 | ``` 69 | pip3.7 install streamlink 70 | ``` 71 | 72 | ### 代码依赖库 73 | 如果系统没有pip,请在安装python3.7后再更改为pip3.7 74 | ``` 75 | pip3.7 install requests 76 | pip3.7 install numpy 77 | pip3.7 install selenium 78 | pip3.7 install Pillow 79 | pip3.7 install sqlalchemy 80 | pip3.7 install apscheduler 81 | pip3.7 install psutil 82 | ``` 83 | 84 | 85 | ### 开启防火墙,这里打开的是80端口,需要根据对应配置的端口来设置 86 | ``` 87 | firewall-cmd --zone=public --add-port=80/tcp --permanent 88 | firewall-cmd --reload 89 | ``` 90 | 91 | ### 如何把当前代码传到服务器或者更新代码(请在服务器当前没有任务时进行更新,虽然已经运行的任务会继续运行,但它不会再自动转码变成mp4,会一直留在temp_videos文件夹里) 92 | ``` 93 | cd ~ 94 | wget https://github.com/HalfMAI/AutoYtB/archive/master.zip 95 | [ -f AutoYtB-master/config.json ] && unzip -o master.zip -x *.json || unzip -o master.zip 96 | rm -f master.zip 97 | cd AutoYtB-master/ 98 | ``` 99 | 100 | # 运行前的配置 101 | 打开目录中的config.json 102 | ``` 103 | { 104 | "serverIP": "XXXXX", <-必需设置,用于pubsubhub的回调地址 105 | "serverPort": "80", <-运行端口 106 | "subSecert": "", <-会自动生成的sercert,用于pubsubhub的订阅校验和服务器身份校验 107 | "is_auto_record": true, <-是否自动录像,请自己看一下服务器空间来设置,默认是false 108 | "driver_type": "chrome", <-account登录模式时使用的浏览器,可选值为chrome和firefox,请根据自己机器上安装的浏览器与驱动配置 109 | "login_retry_times": 3, <-登录重试次数 110 | "subscribeList": [ 111 | { 112 | "mark": "账号标识或备注", <-账号标识或备注,用于在手动开播时会显示在列表中 113 | "opt_code": "XXXXXXX", <-用于手动开播时的操作码,用这个来代替操作账号开播的授权 114 | "login_type": "cookies", <-登录模式,目前支持cookies及account两种模式 115 | "bilibili_cookiesStr": "xxxxxxxxxxx", <-cookies登录模式时必填,输入访问B站时的requestHeader的cookies 116 | "auto_send_dynamic": false, <-开播时是否自动发动态,注意如果你的账号以前没发过动态,先手动去发条动态同意一下协议 117 | "dynamic_template": "转播开始了哦~☞${roomUrl}", <-开播动态内容,变量以${paramName}的形式表示,目前支持的变量仅有roomUrl:直播间地址 118 | "bilibili_areaid": "33", <-自动开播时的区域ID 119 | "youtubeChannelId": "UCWCc8tO-uUl_7SJXIKJACMw,xxxxxxxxx,xxxxxxxxxx", <-订阅的youtube channel_id,可以使用逗号分隔用于多频道(小写逗号) 120 | "twitterId": "kaguramea,xxxxxxxxxxx,xxxxxxxxxx" <-用于监控twitter的ID,可以使用逗号分隔用于多频道(小写逗号) 121 | }, 122 | { 123 | "mark": "账号标识或备注", <-账号标识或备注,用于在手动开播时会显示在列表中 124 | "opt_code": "XXXXXXX", <-用于手动开播时的操作码,用这个来代替操作账号开播的授权 125 | "login_type": "account", <-登录模式,目前支持cookies及account两种模式 126 | "username": "xxxxxxxxxxxx", <-登录账号,account登录模式时必填 127 | "password": "xxxxxxxxxxxx", <-登录密码,account登录模式时必填 128 | "auto_send_dynamic": false, 129 | "dynamic_template": "转播开始了哦~", 130 | "bilibili_areaid": "33", 131 | "youtubeChannelId": "xxxxxxxxxxx", 132 | "twitterId": "" 133 | } 134 | ] 135 | } 136 | ``` 137 | ### 如何运行 138 | 1.cd 到对应的目录,如果是按上面执行的话就是要先cd到AutoYtB文件夹 139 | ``` 140 | cd AutoYtB 141 | ``` 142 | 2.运行以下命令 143 | ``` 144 | nohup python3.7 -u main.py > logfile.txt & 145 | ``` 146 | 147 | ### 如何手动开播 148 | 访问地址:http://{服务器IP或域名}/web/restream.html 149 | 150 | ### 录像的目录 151 | 录像会临时在项目中的 temp_videos 文件里,以flv 的格式保存着,在任务结束 后会转码为mp4封装转移至 archive_videos 中 152 | 153 | ### 如何进行推特检测(此操作有一定延迟,最大延迟大概15分钟,并不能完全实时) 154 | 使用 ifttt 建立监控某个用户的推特,当某用户发推时,调用webhook: 155 | url :http://{服务器IP或域名}/tweet 156 | 请求方法:POST 157 | 请求内容:{ "twitter_acc": "<<<{{UserName}}>>>", "twitter_body":"<<<{{Text}}>>>", "auth": "XXXXXXXXXXX" } 158 | auth里的XXXXXX是指config.json生成的 "subSecert",用来做服务器身份验证的 159 | 160 | ### TODO LIST 161 | - [X] 环境自动安装脚本 162 | - [X] 添加手动下播功能,只需要对应rtmp就可以了 163 | - [ ] 订阅列表添加到config.json的可视化界面和接口吧 164 | - [X] twitcast 支持? 165 | - [X] openREC 支持? 166 | - [X] showroom 支持? 167 | - [ ] Mirrativ 支持? 168 | - [ ] 17live 支持? 169 | - [ ] RELITIY 支持?? 170 | - [X] ifttt 监控推特自动监控上面的其它平台?? 171 | - [X] account登录模式cookies过期自动重新登录 172 | - [ ] 开播动态内容支持更多变量(如来源频道标题等) 173 | - [ ] 等一个有心人做一个预定开播页面 174 | -------------------------------------------------------------------------------- /bilibiliProxy.py: -------------------------------------------------------------------------------- 1 | import traceback 2 | 3 | import requests 4 | from http.cookies import SimpleCookie 5 | from utitls import myLogger 6 | 7 | class BilibiliProxy: 8 | 9 | def __init__(self, cookies_str): 10 | self.session = requests.session() 11 | self.csrf_token = None 12 | self._initWithCookies(cookies_str) 13 | 14 | 15 | def _initWithCookies(self, cookies_str): 16 | cookie = SimpleCookie() 17 | cookie.load(cookies_str) 18 | cookies_dict = {} 19 | for key, morsel in cookie.items(): 20 | cookies_dict[key] = morsel.value 21 | 22 | cookiejar = requests.utils.cookiejar_from_dict(cookies_dict, cookiejar=None, overwrite=True) 23 | self.csrf_token = cookies_dict.get('bili_jct') 24 | 25 | self.session.cookies = cookiejar 26 | 27 | 28 | 29 | def _baseRequestProcess(self, response): 30 | if response == None: 31 | return None 32 | try: 33 | myLogger("Request URL:%s-----------------" % response.request.url) 34 | myLogger("Method:%s, Code:%s,\n text:%s" % (response.request.method, str(response.status_code), response.text)) 35 | if response.status_code == 200: 36 | try: 37 | return response.json() 38 | except Exception: 39 | return None 40 | else: 41 | return None 42 | except Exception as e: 43 | myLogger("request Exception:%s" % str(e)) 44 | myLogger(traceback.format_exc()) 45 | return None 46 | 47 | 48 | def _baseGet(self, url): 49 | try: 50 | myLogger("GET URL:%s--" % url) 51 | response = self.session.get(url, timeout=30) 52 | except Exception as e: 53 | myLogger(str(e)) 54 | myLogger(traceback.format_exc()) 55 | return None 56 | 57 | return self._baseRequestProcess(response) 58 | 59 | 60 | def _basePost(self, url, data): 61 | try: 62 | myLogger("POST URL:%s--" % url) 63 | myLogger("DATA :%s--" % data) 64 | response = self.session.post(url, data=data, timeout=30) 65 | except Exception as e: 66 | myLogger(str(e)) 67 | myLogger(traceback.format_exc()) 68 | return None 69 | return self._baseRequestProcess(response) 70 | 71 | 72 | def startLive(self, room_id, area_id): 73 | resDict = self._basePost( 74 | 'https://api.live.bilibili.com/room/v1/Room/startLive', 75 | { 76 | 'room_id': room_id, 77 | 'platform': 'pc', 78 | 'area_v2': area_id, 79 | 'csrf_token': self.csrf_token 80 | } 81 | ) 82 | if resDict: 83 | if resDict['code'] == 0: 84 | tmp_path = resDict['data']['rtmp']['addr'] 85 | tmp_path = tmp_path if tmp_path.endswith('/') else tmp_path + '/' 86 | rtmp_link = resDict['data']['rtmp']['addr'] + resDict['data']['rtmp']['code'] 87 | myLogger("Current RTMP_LINK:%s" % rtmp_link) 88 | return rtmp_link 89 | else: 90 | return None 91 | 92 | 93 | def stopLive(self, room_id): 94 | resDict = self._basePost( 95 | 'https://api.live.bilibili.com/room/v1/Room/stopLive', 96 | { 97 | 'room_id': room_id, 98 | 'platform': 'pc', 99 | 'csrf_token': self.csrf_token 100 | } 101 | ) 102 | if resDict: 103 | if resDict['code'] != 0: 104 | myLogger('ERROR: StopLive Failed') 105 | 106 | 107 | def getLiveRoomId(self): 108 | resDict = self._baseGet('https://api.live.bilibili.com/i/api/liveinfo') 109 | if resDict: 110 | if resDict['code'] == 0: 111 | return resDict['data']['roomid'] 112 | else: 113 | return None 114 | 115 | 116 | def updateRoomTitle(self, room_id, title): 117 | resDict = self._basePost( 118 | 'https://api.live.bilibili.com/room/v1/Room/update', 119 | { 120 | 'room_id': room_id, 121 | 'title': title, 122 | 'csrf_token': self.csrf_token 123 | } 124 | ) 125 | if resDict: 126 | if resDict['code'] != 0: 127 | myLogger('ERROR: update room title Failed') 128 | 129 | 130 | def getAccInfo(self): 131 | resDict = self._baseGet('https://api.bilibili.com/x/member/web/account') 132 | if resDict: 133 | if resDict['code'] == 0: 134 | return resDict['data'] 135 | else: 136 | myLogger('ERROR: Account no login') 137 | return None 138 | 139 | def send_dynamic(self, content): 140 | self._basePost( 141 | 'http://api.vc.bilibili.com/dynamic_repost/v1/dynamic_repost/repost', 142 | { 143 | 'dynamic_id': '0', 144 | 'type': '4', 145 | 'rid': '0', 146 | 'content': content, 147 | 'at_uids': '', 148 | 'ctrl': '[]', 149 | 'csrf_token': self.csrf_token 150 | } 151 | ) 152 | -------------------------------------------------------------------------------- /config.json: -------------------------------------------------------------------------------- 1 | { 2 | "serverIP": "XXXXX", 3 | "serverPort": "80", 4 | "subSecert": "", 5 | "driver_type": "chrome", 6 | "is_auto_record": false, 7 | "login_retry_times": 3, 8 | "subscribeList": [ 9 | { 10 | "mark": "账号标识或备注", 11 | "opt_code": "XXXXXXX", 12 | "login_type": "cookies", 13 | "bilibili_cookiesStr": "xxxxxxxxxxx", 14 | "auto_send_dynamic": false, 15 | "dynamic_template": "转播开始了哦~☞${roomUrl}", 16 | "bilibili_areaid": "33", 17 | "youtubeChannelId": "UCWCc8tO-uUl_7SJXIKJACMw", 18 | "twitterId": "kaguramea" 19 | }, 20 | { 21 | "mark": "账号标识或备注", 22 | "opt_code": "XXXXXXX", 23 | "login_type": "account", 24 | "auto_send_dynamic": false, 25 | "dynamic_template": "转播开始了哦~☞${roomUrl}", 26 | "username": "xxxxxxxxxxxx", 27 | "password": "xxxxxxxxxxxx", 28 | "bilibili_areaid": "33", 29 | "youtubeChannelId": "xxxxxxxxxxx", 30 | "twitcast": "" 31 | } 32 | ] 33 | } 34 | -------------------------------------------------------------------------------- /login.py: -------------------------------------------------------------------------------- 1 | import io 2 | import time 3 | import traceback 4 | 5 | import requests 6 | import numpy 7 | import utitls 8 | from selenium import webdriver 9 | from PIL import Image, ImageChops 10 | from selenium.webdriver.common.by import By 11 | from selenium.webdriver.support.ui import WebDriverWait as Wait 12 | from selenium.webdriver.support import expected_conditions as Expect 13 | from selenium.webdriver.common.action_chains import ActionChains 14 | 15 | 16 | def login(username, password): 17 | browser = None 18 | try: 19 | if utitls.configJson().get("driver_type", "chrome") == "firefox": 20 | firefox_option = webdriver.FirefoxOptions() 21 | firefox_option.headless = True 22 | browser = webdriver.Firefox(firefox_options=firefox_option) 23 | else: 24 | chrome_options = webdriver.ChromeOptions() 25 | chrome_options.headless = True 26 | chrome_options.add_argument('--no-sandbox') 27 | chrome_options.add_argument('--disable-dev-shm-usage') 28 | chrome_options.add_argument('--disable-sync') 29 | chrome_options.add_argument('--disable-plugins') 30 | chrome_options.add_argument('--disable-extensions') 31 | chrome_options.add_argument('--disable-translate') 32 | browser = webdriver.Chrome(chrome_options=chrome_options) 33 | 34 | browser.get('https://link.bilibili.com/p/center/index') # if no login, it will go to the login page. The main bilibil.com have too much images it will take a lot memory 35 | Wait(browser, 60).until( 36 | Expect.visibility_of_element_located((By.CLASS_NAME, "gt_slider")) 37 | ) 38 | username_input = browser.find_element_by_id("login-username") 39 | username_input.send_keys(username) 40 | password_input = browser.find_element_by_id("login-passwd") 41 | password_input.send_keys(password) 42 | utitls.myLogger('Inputing the username and passwd, username:{}'.format(username)) 43 | 44 | retry_times = 0 45 | max_retry_times = utitls.configJson().get("login_retry_times", 3) 46 | while retry_times < max_retry_times: 47 | do_captcha(browser) 48 | Wait(browser, 20).until( 49 | Expect.visibility_of_element_located((By.CLASS_NAME, "gt_info_tip")) 50 | ) 51 | if Expect.visibility_of_element_located((By.CLASS_NAME, "gt_success")) \ 52 | or Expect.visibility_of_element_located((By.ID, "banner_link")): 53 | utitls.myLogger('Login Success~') 54 | break 55 | retry_times += 1 56 | Wait(browser, 10).until( 57 | Expect.invisibility_of_element_located((By.CLASS_NAME, "gt_fail")) 58 | ) 59 | utitls.myLogger('Login FAIL~') 60 | time.sleep(1) 61 | if retry_times >= max_retry_times: 62 | utitls.myLogger('Retrying MAX') 63 | return "" 64 | 65 | 66 | #check is login Success 67 | time.sleep(5) #wait for the cookies 68 | browser.get('https://link.bilibili.com/p/center/index') 69 | time.sleep(5) #wait for the cookies 70 | cookies = browser.get_cookies() 71 | utitls.myLogger('Setting the Cookies:{}'.format(cookies)) 72 | 73 | cookies_str_array = [] 74 | for cookie in cookies: 75 | cookies_str_array.append(cookie["name"]+"="+cookie["value"]) 76 | browser.quit() 77 | return ";".join(cookies_str_array) 78 | except Exception as e: 79 | utitls.myLogger(traceback.format_exc()) 80 | utitls.myLogger(str(e)) 81 | if browser is not None: 82 | browser.quit() 83 | return "" 84 | 85 | 86 | def do_captcha(browser): 87 | offset = get_captcha_offset(browser) 88 | drag_button(browser, offset) 89 | 90 | 91 | def get_captcha_offset(browser): 92 | slice_image_url = browser.find_element_by_class_name("gt_slice").value_of_css_property("background-image").split('"')[1] 93 | slice_image = Image.open(io.BytesIO(requests.get(slice_image_url).content)) 94 | slice_offset = find_not_transparent_point_offset(slice_image) 95 | cut_image_url = browser.find_element_by_class_name("gt_cut_bg_slice").value_of_css_property("background-image").split('"')[1] 96 | cut_image = Image.open(io.BytesIO(requests.get(cut_image_url).content)) 97 | source_cut_image = Image.new('RGB', (260, 116)) 98 | cut_image_elements = browser.find_elements_by_class_name("gt_cut_bg_slice") 99 | index = 0 100 | for cut_image_element in cut_image_elements: 101 | background_position = cut_image_element.value_of_css_property("background-position") 102 | offset = convert_background_position_to_offset(background_position) 103 | source_cut_image.paste(cut_image.crop(offset), convert_index_to_offset(index)) 104 | index += 1 105 | full_image_url = browser.find_element_by_class_name("gt_cut_fullbg_slice").value_of_css_property("background-image").split('"')[1] 106 | full_image = Image.open(io.BytesIO(requests.get(full_image_url).content)) 107 | source_full_image = Image.new('RGB', (260, 116)) 108 | full_image_elements = browser.find_elements_by_class_name("gt_cut_fullbg_slice") 109 | index = 0 110 | for full_image_element in full_image_elements: 111 | background_position = full_image_element.value_of_css_property("background-position") 112 | offset = convert_background_position_to_offset(background_position) 113 | source_full_image.paste(full_image.crop(offset), convert_index_to_offset(index)) 114 | index += 1 115 | offset = find_different_point_offset(source_cut_image, source_full_image) 116 | return offset - slice_offset 117 | 118 | 119 | def convert_background_position_to_offset(background_position): 120 | background_position_parts = background_position.replace(' ', '').split("px") 121 | x = -int(background_position_parts[0]) 122 | y = -int(background_position_parts[1]) 123 | return x, y, x+10, y+58 124 | 125 | 126 | def convert_index_to_offset(index): 127 | if index >= 26: 128 | return (index - 26) * 10, 58, (index - 25) * 10, 116 129 | else: 130 | return index * 10, 0, (index + 1) * 10, 58 131 | 132 | 133 | def find_not_transparent_point_offset(image): 134 | width, height = image.size 135 | offset_array = [] 136 | for h in range(height): 137 | for w in range(width): 138 | r, g, b, alpha = image.getpixel((w, h)) 139 | if alpha > 150: 140 | offset_array.append(w) 141 | break 142 | offset = min(offset_array) 143 | return offset 144 | 145 | 146 | def find_different_point_offset(image1, image2): 147 | diff_image = ImageChops.difference(image1, image2) 148 | width, height = diff_image.size 149 | offset_array = [] 150 | for h in range(height): 151 | for w in range(width): 152 | r, g, b = diff_image.getpixel((w, h)) 153 | min_rgb = min(r, g, b) 154 | max_rgb = max(r, g, b) 155 | if max_rgb - min_rgb > 40 or max_rgb > 50: 156 | offset_array.append(w) 157 | break 158 | offset = min(offset_array) 159 | return offset 160 | 161 | 162 | def drag_button(browser, offset): 163 | button = browser.find_element_by_class_name("gt_slider_knob") 164 | time_long = round(numpy.random.uniform(3.0, 5.0), 1) 165 | ActionChains(browser).click_and_hold(button).perform() 166 | current_offset = 0 167 | pause_time = round(numpy.random.uniform(0.5, 1.5), 1) 168 | real_drag_time_long = time_long - pause_time 169 | for s in numpy.arange(0, real_drag_time_long, 0.1): 170 | new_offset = round(ease_out_back(s/real_drag_time_long) * offset) 171 | if pause_time > 0: 172 | ActionChains(browser).pause(pause_time) 173 | ActionChains(browser).move_by_offset(new_offset - current_offset, 0).perform() 174 | current_offset = new_offset 175 | pause_time = 0 176 | ActionChains(browser).move_by_offset(offset - current_offset, 0).perform() 177 | ActionChains(browser).pause(0.5).release().perform() 178 | 179 | 180 | def ease_out_back(x): 181 | return 1 + 2.70158 * pow(x - 1, 3) + 1.70158 * pow(x - 1, 2) 182 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | from http.server import HTTPServer 2 | from socketserver import ThreadingMixIn 3 | from requestHandler import RequestHandler 4 | import AutoOperate 5 | import utitls 6 | import time 7 | import traceback 8 | 9 | 10 | class ThreadedHTTPServer(ThreadingMixIn, HTTPServer): 11 | """Handle requests in a separate thread.""" 12 | pass 13 | 14 | def startWebServer(): 15 | # ip = utitls.configJson().get('serverIP') 16 | port = utitls.configJson().get('serverPort') 17 | server = ThreadedHTTPServer(('', int(port)), RequestHandler) 18 | utitls.myLogger('WebServerStarted, Listening on localhost:%s' % port) 19 | # sys.stderr = open('logfile.txt', 'a+', 1) 20 | server.serve_forever() 21 | return server.shutdown() 22 | 23 | def main(): 24 | try: 25 | AutoOperate.preparingAllAccountsCookies() 26 | AutoOperate.clearOldQuests() 27 | AutoOperate.perparingAllComingVideos() 28 | except BaseException as e: 29 | utitls.myLogger(str(e)) 30 | utitls.myLogger(traceback.format_exc()) 31 | startWebServer() 32 | pass 33 | 34 | if __name__ == "__main__": 35 | AutoOperate.Async_subscribeTheList() 36 | try: 37 | while True: 38 | main() 39 | utitls.myLogger('RESTART WERSERVER') 40 | time.sleep(5) 41 | except OSError as e: 42 | utitls.myLogger(str(e)) 43 | utitls.myLogger(traceback.format_exc()) 44 | pass 45 | except KeyboardInterrupt: 46 | utitls.myLogger('Running END-------------------------\n') 47 | -------------------------------------------------------------------------------- /myRequests.py: -------------------------------------------------------------------------------- 1 | import requests 2 | from utitls import configJson, myLogger 3 | import traceback 4 | 5 | def subscribe(callbackURL, channel_id): 6 | _requsetBase(callbackURL, channel_id, 'subscribe') 7 | 8 | def unsubscribe(callbackURL, channel_id): 9 | _requsetBase(callbackURL, channel_id, 'unsubscribe') 10 | 11 | def _requsetBase(callbackURL, channel_id, mode): 12 | response = _basePost( 13 | 'https://pubsubhubbub.appspot.com/subscribe', 14 | { 15 | 'hub.callback': callbackURL, 16 | 'hub.topic': 'https://www.youtube.com/xml/feeds/videos.xml?channel_id=' + channel_id, 17 | 'hub.verify': 'sync', 18 | 'hub.mode': mode, 19 | 'hub.verify_token': '', 20 | 'hub.secret': configJson().get('subSecert', ''), 21 | 'hub.lease_seconds': '' 22 | } 23 | ) 24 | return _baseRequestProcess(response) 25 | 26 | 27 | g_key = 'AIzaSyBQQK9THRp1OzsGtbcIdgmmAn3MCP77G10' #youtube API key, it can call 1000k/3 times, it can be public. 1 call cost 3. 28 | def getYoutubeLiveStreamInfo(vidoeID): 29 | global g_key 30 | resJson = _baseGet('https://www.googleapis.com/youtube/v3/videos?id={}&part=liveStreamingDetails,snippet&key={}'.format(vidoeID, g_key)) 31 | if resJson: 32 | items = resJson.get('items', []) 33 | if len(items) > 0: 34 | if items[0].get('id'): 35 | return items[0] 36 | 37 | return None 38 | else: 39 | return None 40 | else: 41 | return None 42 | 43 | def getYoutubeLiveVideoInfoFromChannelID(channelID, eventType='live'): 44 | global g_key 45 | resJson = _baseGet('https://www.googleapis.com/youtube/v3/search?part=snippet&channelId={}&eventType={}&type=video&key={}'.format(channelID, eventType, g_key)) 46 | if resJson: 47 | items = resJson.get('items', []) 48 | if len(items) > 0: 49 | item = items[0] 50 | videoId = item.get('id', {}).get('videoId') 51 | if videoId: 52 | item = getYoutubeLiveStreamInfo(videoId) 53 | return item 54 | 55 | return None 56 | else: 57 | return None 58 | else: 59 | return None 60 | 61 | 62 | def getUpcomingLiveVideos(channelID): 63 | global g_key 64 | resJson = _baseGet('https://www.googleapis.com/youtube/v3/search?part=snippet&channelId={}&eventType=upcoming&type=video&key={}'.format(channelID, g_key)) 65 | if resJson: 66 | items = resJson.get('items', []) 67 | if len(items) > 0: 68 | ret_videos_id = [] 69 | for v in items: 70 | videoId = v.get('id', {}).get('videoId') 71 | if videoId: 72 | ret_videos_id.append(videoId) 73 | return ret_videos_id 74 | else: 75 | return [] 76 | else: 77 | return [] 78 | 79 | 80 | def isTwitcastingLiving(id): 81 | res = _baseGet('http://api.twitcasting.tv/api/livestatus?user=' + id) 82 | if res == None: 83 | return False 84 | if '"islive":true' in res.text: 85 | return True 86 | return False 87 | 88 | def _baseGet(url): 89 | try: 90 | myLogger("GET URL:%s--" % url) 91 | response = requests.get(url, timeout=30) 92 | except Exception as e: 93 | myLogger(str(e)) 94 | myLogger(traceback.format_exc()) 95 | return None 96 | return _baseRequestProcess(response) 97 | 98 | 99 | def _basePost(url, data): 100 | try: 101 | myLogger("POST URL:%s--" % url) 102 | myLogger("DATA :%s--" % data) 103 | response = requests.post(url, data=data, timeout=30) 104 | except Exception as e: 105 | myLogger(str(e)) 106 | myLogger(traceback.format_exc()) 107 | return None 108 | return _baseRequestProcess(response) 109 | 110 | def _baseRequestProcess(response): 111 | if response == None: 112 | return None 113 | try: 114 | myLogger("Request URL:%s-----------------" % response.request.url) 115 | myLogger("Method:%s, Code:%s,\n text:%s" % (response.request.method, str(response.status_code), response.text)) 116 | if response.status_code == 200: 117 | try: 118 | return response.json() 119 | except Exception: 120 | return None 121 | else: 122 | return None 123 | except Exception as e: 124 | myLogger("request Exception:%s" % str(e)) 125 | myLogger(traceback.format_exc()) 126 | return None 127 | -------------------------------------------------------------------------------- /newInstall.sh: -------------------------------------------------------------------------------- 1 | sudo yum install epel-release -y 2 | sudo yum update -y 3 | 4 | sudo rpm --import http://li.nux.ro/download/nux/RPM-GPG-KEY-nux.ro 5 | sudo rpm -Uvh http://li.nux.ro/download/nux/dextop/el7/x86_64/nux-dextop-release-0-5.el7.nux.noarch.rpm 6 | 7 | sudo yum install -y wget 8 | sudo yum install -y unzip 9 | 10 | wget https://raw.githubusercontent.com/Sporesirius/ffmpeg-install/master/ffmpeg-install 11 | chmod a+x ffmpeg-install 12 | ./ffmpeg-install --install release 13 | rm -f ffmpeg-install 14 | 15 | curl https://intoli.com/install-google-chrome.sh | bash 16 | wget -N https://chromedriver.storage.googleapis.com/2.43/chromedriver_linux64.zip 17 | unzip chromedriver_linux64.zip 18 | chmod +x chromedriver 19 | mv chromedriver /usr/bin/ 20 | rm -f chromedriver_linux64.zip 21 | 22 | sudo yum install -y gcc openssl-devel bzip2-devel libffi libffi-devel sqlite-devel 23 | cd /usr/src 24 | wget https://www.python.org/ftp/python/3.7.0/Python-3.7.0.tgz 25 | tar xzf Python-3.7.0.tgz 26 | cd Python-3.7.0 27 | ./configure --enable-optimizations 28 | make altinstall 29 | rm -f /usr/src/Python-3.7.0.tgz 30 | 31 | pip3.7 install --upgrade pip 32 | pip3.7 install streamlink 33 | pip3.7 install requests 34 | pip3.7 install numpy 35 | pip3.7 install selenium 36 | pip3.7 install Pillow 37 | pip3.7 install Crypto 38 | pip3.7 install sqlalchemy 39 | pip3.7 install apscheduler 40 | pip3.7 install psutil 41 | 42 | firewall-cmd --zone=public --add-port=80/tcp --permanent 43 | firewall-cmd --reload 44 | 45 | cd ~ 46 | wget https://github.com/HalfMAI/AutoYtB/archive/master.zip 47 | [ -f AutoYtB-master/config.json ] && unzip -o master.zip -x *.json || unzip -o master.zip 48 | rm -f master.zip 49 | 50 | shutdown -r now 51 | -------------------------------------------------------------------------------- /questInfo.py: -------------------------------------------------------------------------------- 1 | import utitls 2 | import json 3 | 4 | K_QUEST_JSON_PATH = 'tmp_QuestList.json' 5 | 6 | def initQuestList(): 7 | _saveQuestList([]) 8 | 9 | def _getQuestList(): 10 | ret = [] 11 | try: 12 | with open(K_QUEST_JSON_PATH, 'r', encoding='utf-8') as f: 13 | ret = json.loads(f.read()).get('quest_list') 14 | return ret 15 | except FileNotFoundError: 16 | initQuestList() 17 | return ret 18 | 19 | def _saveQuestList(questList): 20 | tmp_dict = {'quest_list': questList} 21 | with open(K_QUEST_JSON_PATH, 'w', encoding='utf-8') as wf: 22 | json.dump(tmp_dict, wf, indent=4, sort_keys=True) 23 | 24 | 25 | def checkIfInQuest(rtmpLink, isSubscribeQuest=False, accMark=None): 26 | if isSubscribeQuest: 27 | if _getObjWithAccMark(accMark): 28 | return True 29 | else: 30 | return False 31 | else: 32 | if _getObjWithRTMPLink(rtmpLink): 33 | return True 34 | else: 35 | return False 36 | 37 | def updateQuestInfo(key, value, rtmpLink, isSubscribeQuest=False, questAcc=None): 38 | tmp_quest_list = _getQuestList() 39 | for quest in tmp_quest_list: 40 | tmp_can_remove = False 41 | if isSubscribeQuest: 42 | if quest.get('mark') == questAcc: 43 | tmp_can_remove = True 44 | else: 45 | if rtmpLink and quest.get('rtmpLink', "").split('/')[-1] == str(rtmpLink).split('/')[-1]: 46 | tmp_can_remove = True 47 | if tmp_can_remove: 48 | quest[key] = value 49 | break 50 | _saveQuestList(tmp_quest_list) 51 | 52 | 53 | def addQuest(forwardLinkOrign, rtmpLink, isSubscribeQuest=False, questAcc=None): 54 | if checkIfInQuest(rtmpLink, isSubscribeQuest, questAcc): 55 | return 56 | 57 | forwardLinkOrign = str(forwardLinkOrign) 58 | rtmpLink = str(rtmpLink) 59 | questDict = { 60 | 'isDead': False, 61 | 'forwardLinkOrign': forwardLinkOrign, 62 | 'rtmpLink': rtmpLink, 63 | 'isSubscribeQuest': isSubscribeQuest, 64 | 'title': None 65 | } 66 | utitls.myLogger('AddQuest LOG:\n AddQuest QUEST:%s' % questDict) 67 | tmp_quest_list = _getQuestList() 68 | tmp_quest_list.append(questDict) 69 | utitls.myLogger('Current Quest List:\n,{}'.format(tmp_quest_list)) 70 | _saveQuestList(tmp_quest_list) 71 | 72 | def removeQuest(rtmpLink, isSubscribeQuest=False, questAcc=None): 73 | tmp_quest_list = _getQuestList() 74 | for quest in tmp_quest_list: 75 | tmp_can_remove = False 76 | if isSubscribeQuest: 77 | if quest.get('mark') == questAcc: 78 | tmp_can_remove = True 79 | else: 80 | if rtmpLink and quest.get('rtmpLink', "").split('/')[-1] == str(rtmpLink).split('/')[-1]: 81 | tmp_can_remove = True 82 | if tmp_can_remove: 83 | utitls.myLogger('RemoveQuest LOG:\n Removed QUEST:%s' % quest) 84 | tmp_quest_list.remove(quest) 85 | utitls.myLogger('Current Quest List:\n,{}'.format(tmp_quest_list)) 86 | _saveQuestList(tmp_quest_list) 87 | return 88 | 89 | def _getObjWithAccMark(accMark): 90 | tmp_quest_list = _getQuestList() 91 | ret = None 92 | for quest in tmp_quest_list: 93 | if quest.get('mark', "") == accMark: 94 | ret = quest 95 | break 96 | return ret 97 | 98 | def _getObjWithRTMPLink(rtmpLink): 99 | tmp_quest_list = _getQuestList() 100 | ret = None 101 | for quest in tmp_quest_list: 102 | # just check the key. Bilibili's rtmp will be different if rtmp link is got from different IP 103 | if quest.get('rtmpLink', "").split('/')[-1] == rtmpLink.split('/')[-1]: 104 | ret = quest 105 | break 106 | return ret 107 | 108 | 109 | def getQuestListStr(): 110 | ret = '' 111 | tmp_quest_list = getQuestList_AddStarts() 112 | for quest in tmp_quest_list: 113 | ret += '---------Quest Start------------\n' 114 | for k,v in quest.items(): 115 | ret += '{}: {}\n'.format(k, v) 116 | ret += '---------Quest End--------------\n' 117 | return ret 118 | 119 | def getQuestList_AddStarts(): 120 | ret = [] 121 | tmp_quest_list = _getQuestList() 122 | for quest in tmp_quest_list: 123 | questDict = quest 124 | questDict['rtmpLink'] = 'rtmp://********************' + quest.get('rtmpLink', "")[-8:] 125 | ret.append(questDict) 126 | return ret 127 | -------------------------------------------------------------------------------- /requestHandler.py: -------------------------------------------------------------------------------- 1 | from http.server import BaseHTTPRequestHandler 2 | from urllib.parse import urlsplit,parse_qs 3 | import xml.etree.ElementTree as ET 4 | import requests 5 | 6 | import traceback 7 | import json 8 | import os 9 | import zlib 10 | import re 11 | from mimetypes import types_map 12 | 13 | import utitls 14 | from myRequests import getYoutubeLiveStreamInfo 15 | from subprocessOp import async_forwardStream 16 | from AutoOperate import Async_forwardToBilibili, getBilibiliProxy 17 | 18 | from questInfo import checkIfInQuest, getQuestListStr, getQuestList_AddStarts, updateQuestInfo, _getObjWithRTMPLink, _getObjWithAccMark 19 | import scheduler 20 | 21 | 22 | class RequestHandler(BaseHTTPRequestHandler): 23 | 24 | def gzip_encode(self, content): 25 | gzip_compress = zlib.compressobj(9, zlib.DEFLATED, zlib.MAX_WBITS | 16) 26 | data = gzip_compress.compress(content) + gzip_compress.flush() 27 | return data 28 | 29 | def do_GET(self): 30 | request_path = self.path 31 | rc = 404 32 | rb = None 33 | sh = { 34 | 'Content-Encoding': 'gzip' 35 | } 36 | params = parse_qs(urlsplit(request_path).query) 37 | 38 | try: 39 | if request_path.startswith('/web/'): 40 | request_path = request_path.split('?')[0] 41 | fname, ext = os.path.splitext(request_path) 42 | if ext in (".html", ".css", ".js"): 43 | tmp_filePath = os.path.join(os.getcwd()) 44 | for v in request_path.split('/'): 45 | tmp_filePath = os.path.join(tmp_filePath, v) 46 | with open(tmp_filePath, 'r', encoding='utf-8') as f: 47 | lastMtime = self.date_time_string(os.fstat(f.fileno()).st_mtime) 48 | if self.headers.get('If-Modified-Since') == lastMtime: 49 | self.send_response(304) 50 | self.end_headers() 51 | return 52 | 53 | self.send_response(200) 54 | sh['Content-type'] = types_map[ext] 55 | sh['Cache-Control'] = 'max-age=72000' 56 | fb = f.read() 57 | rb = self.gzip_encode(fb.encode('utf-8')) 58 | sh['Content-length'] = len(rb) 59 | sh['Last-Modified'] = lastMtime 60 | for key in sh: 61 | self.send_header(key, sh[key]) 62 | self.end_headers() 63 | self.wfile.write(rb) 64 | return 65 | except Exception: 66 | utitls.myLogger(traceback.format_exc()) 67 | self.send_error(404) 68 | self.end_headers() 69 | return 70 | 71 | if request_path.startswith('/get_manual_json'): 72 | rc = 200 73 | ret_dic = utitls.manualJson() 74 | ret_dic['des_dict'] = [] #clear the des 75 | 76 | tmp_acc_mark_list = [] 77 | for sub in utitls.configJson().get('subscribeList', []): 78 | tmp_mark = sub.get('mark') 79 | if tmp_mark: 80 | tmp_acc_mark_list.append(tmp_mark) 81 | ret_dic['acc_mark_list'] = tmp_acc_mark_list 82 | rb = json.dumps(ret_dic) 83 | elif request_path.startswith('/questlist'): 84 | rc = 200 85 | rb = json.dumps(getQuestList_AddStarts()) 86 | elif request_path.startswith('/live_restream?'): 87 | forwardLink_list = params.get('forwardLink', None) 88 | restreamRtmpLink_list = params.get('restreamRtmpLink', None) 89 | if forwardLink_list and restreamRtmpLink_list: 90 | tmp_forwardLink = forwardLink_list[0].strip() 91 | tmp_rtmpLink = restreamRtmpLink_list[0].strip() 92 | 93 | if 'rtmp://' in tmp_rtmpLink: 94 | if utitls.checkIsSupportForwardLink(tmp_forwardLink): 95 | isForwardLinkFormateOK = True 96 | else: 97 | isForwardLinkFormateOK = False 98 | 99 | rc = 200 100 | if isForwardLinkFormateOK: 101 | if checkIfInQuest(tmp_rtmpLink) == False: 102 | #try to restream 103 | async_forwardStream(tmp_forwardLink, tmp_rtmpLink, False) 104 | rb = json.dumps({"code": 0, "msg": "请求成功。请等待大概30秒,网络不好时程序会自动重试30次。也可以查看任务状态看是否添加成功。\ 105 | \nRequesting ForwardLink: {},\nRequesting RestreamRtmpLink: {}\n\n".format(tmp_forwardLink, tmp_rtmpLink)}) 106 | else: 107 | rb = json.dumps({"code": 1, "msg": "当前推流已经在任务中. \nRequesting ForwardLink: {},\nRequesting RestreamRtmpLink: {}\n\n\n-----------------CurrentQuests:\n{}".format(tmp_forwardLink, tmp_rtmpLink, getQuestListStr())}) 108 | else: 109 | rb = json.dumps({"code": -3, "msg": "来源地址格式错误, 请查看上面支持的格式"}) 110 | elif tmp_rtmpLink.startswith('ACCMARK='): 111 | params = parse_qs(tmp_rtmpLink) 112 | acc_list = params.get('ACCMARK', None) 113 | opt_code_list = params.get('OPTC', None) 114 | is_send_dynamic_list = params.get('SEND_DYNAMIC', None) 115 | dynamic_words_list = params.get('DYNAMIC_WORDS', None) 116 | is_should_record_list = params.get('IS_SHOULD_RECORD', None) 117 | b_title_list = params.get('B_TITLE', None) 118 | 119 | rc = 200 120 | if acc_list and opt_code_list and is_send_dynamic_list and dynamic_words_list: 121 | acc_mark = acc_list[0].strip() 122 | opt_code = opt_code_list[0].strip() 123 | is_send_dynamic = is_send_dynamic_list[0].strip() 124 | dynamic_words = dynamic_words_list[0].strip() 125 | is_should_record = is_should_record_list[0].strip() 126 | b_title = None 127 | if b_title_list: 128 | b_title = b_title_list[0].strip() 129 | 130 | 131 | tmp_is_acc_exist = False 132 | for sub in utitls.configJson().get('subscribeList', []): 133 | tmp_mark = sub.get('mark', "") 134 | if tmp_mark == acc_mark: 135 | if opt_code == sub.get('opt_code', ""): 136 | tmp_is_acc_exist = True 137 | sub['auto_send_dynamic'] = True if is_send_dynamic == '1' else False 138 | sub['dynamic_template'] = dynamic_words + "${roomUrl}" 139 | sub['is_should_record'] = True if is_should_record == '1' else False 140 | if b_title: 141 | sub['change_b_title'] = b_title 142 | Async_forwardToBilibili(sub, tmp_forwardLink, isSubscribeQuest=False) 143 | break 144 | 145 | if tmp_is_acc_exist: 146 | rb = json.dumps({"code": 0, "msg": "请求成功。请等待大概30秒,网络不好时程序会自动重试30次。也可以查看任务状态看是否添加成功。\ 147 | \nRequesting ForwardLink: {},\nRequesting RestreamRtmpLink: {}\n\n".format(tmp_forwardLink, tmp_rtmpLink)}) 148 | else: 149 | rb = json.dumps({"code": -5, "msg": "当前账号不存在或者账号操作码错误:{}".format(acc_mark)}) 150 | 151 | else: 152 | rc = 200 153 | rb = json.dumps({"code": -4, "msg": "RTMPLink格式错误!!! bilibili的RTMPLink格式是两串合起来。\nEXAMPLE:rtmp://XXXXXX.acg.tv/live-js/?streamname=live_XXXXXXX&key=XXXXXXXXXX"}) 154 | elif request_path.startswith('/bilibili_opt?'): 155 | rc = 200 156 | acc_list = params.get('acc', None) 157 | opt_code_list = params.get('opt_code', None) 158 | dynamic_words_list = params.get('sendDynamic', None) 159 | b_title_list = params.get('changeTitle', None) 160 | refreshRTMP_list = params.get('refreshRTMP', None) 161 | killRTMP_list = params.get('killRTMP', None) 162 | if acc_list and opt_code_list: 163 | acc = acc_list[0] 164 | opt_code = opt_code_list[0] 165 | 166 | curSub = utitls.getSubWithKey('mark', acc) 167 | if curSub.get('opt_code') == opt_code: 168 | if dynamic_words_list: 169 | dynamic_words = dynamic_words_list[0] 170 | b = getBilibiliProxy(curSub) 171 | t_room_id = b.getLiveRoomId() 172 | t_cur_blive_url = 'https://live.bilibili.com/' + t_room_id 173 | b.send_dynamic("{}\n{}".format(dynamic_words, t_cur_blive_url)) 174 | elif b_title_list: 175 | b_title = b_title_list[0] 176 | b = getBilibiliProxy(curSub) 177 | t_room_id = b.getLiveRoomId() 178 | b.updateRoomTitle(t_room_id, b_title) 179 | elif refreshRTMP_list: 180 | refreshRTMP = refreshRTMP_list[0] 181 | if refreshRTMP == '1': 182 | b = getBilibiliProxy(curSub) 183 | t_room_id = b.getLiveRoomId() 184 | b.startLive(t_room_id, '199') 185 | elif killRTMP_list: 186 | killRTMP = killRTMP_list[0] 187 | if killRTMP == '1': 188 | cur_quest = _getObjWithAccMark(acc) 189 | if cur_quest: 190 | updateQuestInfo('isDead', True, cur_quest.get('rtmpLink')) 191 | utitls.kill_child_processes(cur_quest.get('pid', None)) 192 | rb = json.dumps({"code": 0, "msg": "操作成功"}) 193 | else: 194 | rb = json.dumps({"code": -5, "msg": "当前账号不存在或者账号操作码错误:{}".format(acc)}) 195 | elif request_path.startswith('/kill_quest?'): 196 | rc = 200 197 | tmp_rtmpLink_list = params.get('rtmpLink', None) 198 | if tmp_rtmpLink_list: 199 | tmp_rtmpLink = tmp_rtmpLink_list[0].strip() 200 | tmp_quest = _getObjWithRTMPLink(tmp_rtmpLink) 201 | if tmp_quest != None: 202 | updateQuestInfo('isDead', True, tmp_rtmpLink) 203 | utitls.kill_child_processes(tmp_quest.get('pid', None)) 204 | rb = json.dumps({"code": 0, "msg": "操作成功"}) 205 | else: 206 | rb = json.dumps({"code": -1, "msg": "查找不到对应的任务:{},操作失败!!".format(tmp_rtmpLink)}) 207 | elif request_path.startswith('/addRestreamSrc?'): 208 | rc = 200 209 | srcNote_list = params.get('srcNote', None) 210 | srcLink_list = params.get('srcLink', None) 211 | if srcNote_list and srcLink_list: 212 | tmp_srcNote = srcNote_list[0].strip() 213 | tmp_srcLink = srcLink_list[0].strip() 214 | utitls.addManualSrc(tmp_srcNote, tmp_srcLink) 215 | rb = json.dumps({"code": 0, "msg":"添加成功"}) 216 | elif request_path.startswith('/addRtmpDes?'): 217 | rc = 200 218 | rtmpNote_list = params.get('rtmpNote', None) 219 | rtmpLink_list = params.get('rtmpLink', None) 220 | if rtmpNote_list and rtmpLink_list: 221 | tmp_rtmpNote = rtmpNote_list[0].strip() 222 | tmp_rtmpLink = rtmpLink_list[0].strip() 223 | utitls.addManualDes(tmp_rtmpNote, tmp_rtmpLink) 224 | rb = json.dumps({"code": 0, "msg":"添加成功"}) 225 | elif request_path.startswith('/subscribe?'): 226 | hub_challenge_list = params.get('hub.challenge', None) 227 | if None != hub_challenge_list: 228 | rc = 202 229 | rb = hub_challenge_list[0] 230 | 231 | 232 | ####### 233 | if rb: 234 | rb = self.gzip_encode(rb.encode('utf-8')) 235 | sh['Content-length'] = len(rb) 236 | 237 | self.send_response(rc) 238 | for key in sh: 239 | self.send_header(key, sh[key]) 240 | self.end_headers() 241 | 242 | if None != rb: 243 | try: 244 | self.wfile.write(rb) 245 | except Exception: 246 | print(traceback.format_exc()) 247 | 248 | 249 | ############################################################### 250 | 251 | 252 | def do_POST(self): 253 | request_path = self.path 254 | rc = 404 255 | utitls.myLogger("\n----- Request POST Start ----->\n") 256 | utitls.myLogger(request_path) 257 | 258 | content_length = int(self.headers['Content-Length']) 259 | post_data = self.rfile.read(content_length) 260 | 261 | utitls.myLogger(self.headers) 262 | utitls.myLogger(post_data) 263 | utitls.myLogger("<----- Request POST End -----\n") 264 | 265 | if '/subscribe' in request_path: 266 | # check the secert 267 | secert = self.headers.get('X-Hub-Signature', '').split('=')[1] 268 | if utitls.verifySecert(secert, post_data): 269 | try: 270 | tree = ET.ElementTree(ET.fromstring(post_data.decode())) 271 | except Exception: 272 | utitls.myLogger(traceback.format_exc()) 273 | self.send_response(rc) 274 | self.end_headers() 275 | return 276 | 277 | ns = {'dfns': 'http://www.w3.org/2005/Atom', 'yt': 'http://www.youtube.com/xml/schemas/2015', 'at': 'http://purl.org/atompub/tombstones/1.0'} 278 | root = tree.getroot() 279 | 280 | if root.find('dfns:title', ns) != None: 281 | tmp_feedTitle = root.find('dfns:title', ns).text 282 | tmp_feedUpadatedTime = root.find('dfns:updated', ns).text 283 | try: 284 | rc = 204 285 | entry = root.findall('dfns:entry', ns)[0] #maybe more than one? 286 | tmp_entry_title = entry.find('dfns:title', ns).text 287 | tmp_entry_videoId = entry.find('yt:videoId', ns).text 288 | tmp_entry_channelId = entry.find('yt:channelId', ns).text 289 | tmp_entry_link = entry.find('dfns:link', ns).attrib.get('href') 290 | tmp_entry_publishedTime = entry.find('dfns:published', ns).text 291 | tmp_entry_updatedTime = entry.find('dfns:updated', ns).text 292 | 293 | utitls.myLogger("%s, %s" % (tmp_feedTitle, tmp_feedUpadatedTime)) 294 | utitls.myLogger("%s, %s, %s, %s, %s, %s " % ( 295 | tmp_entry_title, tmp_entry_videoId, tmp_entry_channelId, tmp_entry_link, tmp_entry_publishedTime, tmp_entry_updatedTime) 296 | ) 297 | #try to restream 298 | for tmp_subscribe_obj in utitls.getSubInfosWithSubChannelId(tmp_entry_channelId): 299 | tmp_acc_mark = tmp_subscribe_obj.get('mark', "") 300 | tmp_area_id = tmp_subscribe_obj.get('bilibili_areaid', '199') 301 | # tmp_live_link = 'https://www.youtube.com/channel/{}/live'.format(tmp_entry_channelId) 302 | tmp_live_link = tmp_entry_link 303 | 304 | item = getYoutubeLiveStreamInfo(tmp_entry_videoId) 305 | liveStreamingDetailsDict = None 306 | if item: 307 | liveStreamingDetailsDict = item.get('liveStreamingDetails', None) 308 | if liveStreamingDetailsDict: 309 | utitls.myLogger("The Sub liveStreamingDetails:{}".format(liveStreamingDetailsDict)) 310 | tmp_is_live = liveStreamingDetailsDict.get('concurrentViewers', None) 311 | tmp_actual_start_time = liveStreamingDetailsDict.get('actualStartTime', None) 312 | tmp_scheduled_start_time = liveStreamingDetailsDict.get('scheduledStartTime', None) 313 | tmp_is_end = liveStreamingDetailsDict.get('actualEndTime', None) 314 | 315 | if tmp_is_end: 316 | #kill the End stream proccess 317 | tmp_quest = _getObjWithAccMark(tmp_subscribe_obj.get('mark')) 318 | if tmp_quest != None: 319 | utitls.kill_child_processes(tmp_quest.get('pid', None)) 320 | elif tmp_is_live or tmp_actual_start_time: 321 | Async_forwardToBilibili(tmp_subscribe_obj, tmp_live_link, tmp_entry_title, tmp_area_id) 322 | elif tmp_scheduled_start_time: 323 | # scheduled the quest 324 | job_id = 'Acc:{},VideoID:{}'.format(tmp_acc_mark, tmp_entry_videoId) 325 | # the job will run before 30 mins 326 | scheduler.add_date_job(tmp_scheduled_start_time, job_id, Async_forwardToBilibili, 327 | (tmp_subscribe_obj, tmp_live_link, tmp_entry_title, tmp_area_id) 328 | ) 329 | else: 330 | Async_forwardToBilibili(tmp_subscribe_obj, tmp_live_link, tmp_entry_title, tmp_area_id) 331 | except Exception: 332 | rc = 404 333 | utitls.myLogger(traceback.format_exc()) 334 | else: 335 | utitls.myLogger("verifySecert Failed with:" + secert) 336 | elif '/tweet' == request_path: 337 | try: 338 | postDict = json.loads(post_data) 339 | # check the secert 340 | secert = postDict.get('auth', '') 341 | if secert == utitls.configJson().get('subSecert', ''): 342 | tmp_acc = postDict.get('twitter_acc', "") 343 | tmp_body = postDict.get('twitter_body', "") 344 | rc = 200 345 | 346 | link_list = re.findall(r"(https://t.co/.+)", tmp_body) 347 | link_list = list(set(link_list)) # remove the duplicates items 348 | if len(link_list) > 0: 349 | l = link_list[0] # I THINK they will just sent one link, so just use the first one 350 | r = requests.get(l) 351 | redirect_url = r.url # get the redirect url 352 | if 'twitter.com' not in redirect_url \ 353 | and utitls.checkIsSupportForwardLink(redirect_url): 354 | for cur_sub in utitls.getSubInfosWithSubTwitterId(tmp_acc): 355 | if cur_sub: 356 | tmp_area_id = cur_sub.get('bilibili_areaid', '199') 357 | Async_forwardToBilibili(cur_sub, redirect_url, "THIS TITLE IS USENESS", tmp_area_id) 358 | except Exception: 359 | utitls.myLogger(traceback.format_exc()) 360 | self.send_response(rc) 361 | self.end_headers() 362 | -------------------------------------------------------------------------------- /scheduler.py: -------------------------------------------------------------------------------- 1 | from pytz import utc 2 | from datetime import datetime, timedelta 3 | from apscheduler.schedulers.background import BackgroundScheduler 4 | from apscheduler.jobstores.base import ConflictingIdError 5 | from utitls import myLogger 6 | 7 | g_main_scheduler = None 8 | def __init__(): 9 | global g_main_scheduler 10 | g_main_scheduler = BackgroundScheduler(timezone=utc) 11 | g_main_scheduler.add_jobstore('sqlalchemy', url='sqlite:///jobs.sqlite') 12 | g_main_scheduler.start() 13 | 14 | log_jobs() 15 | 16 | import threading 17 | myLogger("g_main_scheduler in this thread:{}".format(threading.current_thread())) 18 | 19 | 20 | def add_date_job(datetime_str, job_id, task, args_): 21 | global g_main_scheduler 22 | run_time = datetime.strptime(datetime_str, '%Y-%m-%dT%H:%M:%S.%fZ') 23 | run_time = run_time - timedelta(minutes=30) 24 | 25 | try: 26 | g_main_scheduler.add_job(task, args=args_, id=job_id, name=task.__qualname__, next_run_time=run_time, misfire_grace_time=3600*2) 27 | except ConflictingIdError: 28 | g_main_scheduler.modify_job(job_id, func=task, args=args_, name=task.__qualname__, next_run_time=run_time) 29 | log_jobs() 30 | 31 | 32 | def log_jobs(): 33 | global g_main_scheduler 34 | for v in g_main_scheduler.get_jobs(): 35 | myLogger("jobId:{}, jobName:{}, jobNextTime{}".format(v.id, v.name, v.next_run_time)) 36 | 37 | 38 | def get_jobs(): 39 | global g_main_scheduler 40 | return g_main_scheduler.get_jobs() 41 | 42 | # init the scheduler 43 | __init__() 44 | -------------------------------------------------------------------------------- /subprocessOp.py: -------------------------------------------------------------------------------- 1 | import os, shutil 2 | import subprocess 3 | import time, datetime 4 | import json 5 | import traceback 6 | import re 7 | 8 | import utitls 9 | import questInfo 10 | import myRequests 11 | 12 | def __runCMDSync(cmd, isLog=True): 13 | try: 14 | p = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) 15 | pid = p.pid 16 | if isLog: 17 | utitls.myLogger("CMD RUN START with PID:{}\nCMD: {}".format(pid, cmd)) 18 | try: 19 | rtmpLink = cmd.partition('-f flv "')[2].partition('"')[0] #get the first -f link 20 | if rtmpLink.startswith('rtmp://'): 21 | questInfo.updateQuestInfo('pid', pid, rtmpLink) 22 | except Exception: pass 23 | out, err = p.communicate() 24 | errcode = p.returncode 25 | if isLog: 26 | utitls.myLogger("CMD RUN END with PID:{}\nCMD: {}\nOUT: {}\nERR: {}\nERRCODE: {}".format(pid, cmd, out, err, errcode)) 27 | except Exception as e: 28 | out, err, errcode = None, e, -1 29 | utitls.myLogger(traceback.format_exc()) 30 | return out, err, errcode 31 | 32 | 33 | def _getYoutube_m3u8_sync(youtubeLink, isLog=True): 34 | out, err, errcode = None, None, -1 35 | 36 | tmp_retryTime = 0 37 | while tmp_retryTime < 2: 38 | out, err, errcode = __runCMDSync('youtube-dl --no-check-certificate -j {}'.format(youtubeLink), isLog) 39 | out = out.decode('utf-8') if isinstance(out, (bytes, bytearray)) else out 40 | if errcode == 0: 41 | try: 42 | vDict = json.loads(out) 43 | except Exception: 44 | vDict = None 45 | if vDict: 46 | if vDict.get('is_live') != True: 47 | return out, None, err, 999 #mean this is not a live 48 | title = vDict.get('uploader', '') + '_' + vDict.get('title', '') 49 | url = vDict.get('url', '') 50 | if url.endswith('.m3u8'): 51 | return url, title, err, errcode 52 | else: 53 | tmp_retryTime += 1 54 | time.sleep(30) 55 | 56 | utitls.myLogger("_getYoutube_m3u8_sync SOURCE:{} ERROR:{}".format(youtubeLink, out)) 57 | return out, None, err, errcode 58 | 59 | def resolveStreamToM3u8(streamLink, isLog=True): 60 | out, title, err, errcode = None, streamLink, None, -1 61 | 62 | tmp_retryTime = 0 63 | while tmp_retryTime < 2: 64 | out, err, errcode = __runCMDSync('streamlink -j "{}" best'.format(streamLink), isLog) 65 | out = out.decode('utf-8') if isinstance(out, (bytes, bytearray)) else out 66 | if errcode == 0: 67 | try: 68 | vDict = json.loads(out) 69 | except Exception: 70 | vDict = None 71 | if vDict: 72 | streamM3U8 = vDict.get('url', None) 73 | if streamM3U8 == None: 74 | return out, title, err, 999 #mean this is not a live 75 | 76 | tmp_title = streamLink 77 | tmp_uploader = "" 78 | 79 | # just pack the title 80 | videoId = streamLink.partition('/watch?v=')[2] 81 | if 'youtu.be/' in streamLink: 82 | videoId = streamLink.partition('youtu.be/')[2] 83 | videoId = videoId.split('/')[0] 84 | 85 | c_l = re.findall(r"channel/(.*)/live", streamLink) 86 | if len(c_l) > 0: # find the channelID 87 | channelID = c_l[0] 88 | item = myRequests.getYoutubeLiveVideoInfoFromChannelID(channelID) 89 | if item == None: 90 | item = myRequests.getYoutubeLiveVideoInfoFromChannelID(channelID, 'upcoming') 91 | if item: 92 | snippet = item.get('snippet', {}) 93 | tmp_title = snippet.get('title', streamLink) 94 | tmp_uploader = snippet.get('channelTitle', channelID) 95 | elif videoId != '': 96 | item = myRequests.getYoutubeLiveStreamInfo(videoId) 97 | if item: 98 | snippet = item.get('snippet', {}) 99 | tmp_title = snippet.get('title', streamLink) 100 | tmp_uploader = snippet.get('channelTitle', "") 101 | 102 | title = "{}_{}".format(tmp_uploader, tmp_title) 103 | m3u8Link = streamLink 104 | return m3u8Link, title, err, errcode 105 | else: 106 | tmp_retryTime += 1 107 | time.sleep(30) 108 | 109 | if isLog: 110 | utitls.myLogger("resolveStreamToM3u8 SOURCE:{} ERROR:{}".format(streamLink, out)) 111 | return out, title, err, errcode 112 | 113 | 114 | def async_forwardStream(forwardLink, outputRTMP, isSubscribeQuest, subscribe_obj=None): 115 | utitls.runFuncAsyncThread(_forwardStream_sync, (forwardLink, outputRTMP, isSubscribeQuest, subscribe_obj)) 116 | def _forwardStream_sync(forwardLink, outputRTMP, isSubscribeQuest, subscribe_obj=None): 117 | tmp_quest = questInfo._getObjWithRTMPLink(outputRTMP) 118 | if tmp_quest: 119 | if tmp_quest.get('isRestart') == None: 120 | utitls.myLogger("_forwardStream_sync ERROR: rtmp already in quest!!!!\n forwardLink:%s, \n rtmpLink:%s" % (forwardLink, outputRTMP)) 121 | return 122 | else: 123 | questInfo.addQuest(forwardLink, outputRTMP, isSubscribeQuest) 124 | 125 | if outputRTMP.startswith('rtmp://'): 126 | is_stream_playable = False 127 | 128 | tmp_title = forwardLink # default title is the forwardLink 129 | tmp_forwardLink = forwardLink 130 | if 'twitcasting.tv/' in tmp_forwardLink: 131 | #('https://www.', 'twitcasting.tv/', 're2_takatsuki/fwer/aeqwet') 132 | tmp_twitcasID = tmp_forwardLink.partition('twitcasting.tv/')[2] 133 | tmp_twitcasID = tmp_twitcasID.split('/')[0] 134 | # if using the streamlink, it should be start with hlsvariant:// 135 | tmp_forwardLink = 'hlsvariant://twitcasting.tv/{}/metastream.m3u8/?video=1'.format(tmp_twitcasID) 136 | is_stream_playable = True 137 | else: 138 | m3u8Link, tmp_title, err, errcode = resolveStreamToM3u8(tmp_forwardLink) 139 | tmp_forwardLink = forwardLink # use the orgin streamlink 140 | if errcode == 0: 141 | is_stream_playable = True 142 | else: 143 | utitls.myLogger("_forwardStream_sync ERROR: Unsupport forwardLink:%s" % tmp_forwardLink) 144 | is_stream_playable = False 145 | 146 | if is_stream_playable == True: 147 | questInfo.updateQuestInfo('title', tmp_title, outputRTMP) 148 | if subscribe_obj: 149 | questInfo.updateQuestInfo('AccountMARK', subscribe_obj.get("mark", ""), outputRTMP) 150 | questInfo.updateQuestInfo('Live_URL', subscribe_obj.get("cur_blive_url", ""), outputRTMP) 151 | tmp_retryTime = 0 152 | tmp_cmdStartTime = time.time() 153 | while tmp_retryTime <= 6: # must be <= 154 | # try to restream 155 | out, err, errcode = _forwardStreamCMD_sync(tmp_title, subscribe_obj, tmp_forwardLink, outputRTMP) 156 | 157 | out = out.decode('utf-8') if isinstance(out, (bytes, bytearray)) else out 158 | if ('[cli][info] Stream ended' in out) and (time.time() - tmp_cmdStartTime > 180): # this will cause the retry out, it good as so far. 159 | utitls.myLogger("_forwardStreamCMD_sync LOG: Stream ended=======<") 160 | break 161 | 162 | isQuestDead = questInfo._getObjWithRTMPLink(outputRTMP).get('isDead', False) 163 | if errcode == -9 or isQuestDead or isQuestDead == 'True': 164 | utitls.myLogger("_forwardStreamCMD_sync LOG: Kill Current procces by rtmp:%s" % outputRTMP) 165 | break 166 | # maybe can ignore the error if ran after 2min? 167 | if time.time() - tmp_cmdStartTime < 120: 168 | tmp_retryTime += 1 # make it can exit 169 | else: 170 | tmp_retryTime = 0 # let every Connect success reset the retrytime 171 | time.sleep(10) # one m3u8 can hold 20 secounds or less 172 | tmp_cmdStartTime = time.time() #import should not miss it. 173 | utitls.myLogger('_forwardStream_sync LOG: CURRENT RETRY TIME:%s' % tmp_retryTime) 174 | utitls.myLogger("_forwardStream_sync LOG RETRYING___________THIS:\ninputM3U8:%s, \noutputRTMP:%s" % (forwardLink, outputRTMP)) 175 | else: 176 | utitls.myLogger("_forwardStream_sync ERROR: Invalid outputRTMP:%s" % outputRTMP) 177 | 178 | questInfo.removeQuest(outputRTMP) 179 | 180 | # https://judge2020.com/restreaming-a-m3u8-hls-stream-to-youtube-using-ffmpeg/ 181 | def _forwardStreamCMD_sync(title, subscribe_obj, inputStreamLink, outputRTMP): 182 | os.makedirs('archive_videos', exist_ok=True) 183 | os.makedirs('temp_videos', exist_ok=True) 184 | utitls.myLogger("_forwardStream_sync LOG:%s, %s" % (inputStreamLink, outputRTMP)) 185 | title = title.replace('https', '') 186 | title = title.replace('http', '') 187 | title = title.replace('hlsvariant', '') 188 | reserved_list = ['/', '\\', ':', '?', '%', '*', '|', '"', '.', ' ', '<', '>'] 189 | for val in reserved_list: 190 | title = title.replace(val, '_') 191 | 192 | out, err, errcode = None, None, None 193 | recordFileName_nosuffix = datetime.datetime.now().strftime("%Y-%m-%dT%H-%M-%S") + "_" + utitls.remove_emoji(title.strip()) 194 | recordFilePath = os.path.join( 195 | os.getcwd(), 196 | 'temp_videos', 197 | recordFileName_nosuffix + '.flv' 198 | ) 199 | 200 | # tmp_input = 'ffmpeg -loglevel error -i "{}"'.format(inputStreamLink) 201 | tmp_input = 'streamlink --retry-streams 6 --retry-max 10 --retry-open 10 --hls-timeout 120 --hls-playlist-reload-attempts 10 -O {} best|ffmpeg -loglevel error -i pipe:0'.format(inputStreamLink) 202 | tmp_out_rtmp = '-f flv "{}"'.format(outputRTMP) 203 | tmp_out_file = '-y -f flv "{}"'.format(recordFilePath) 204 | 205 | tmp_encode = '-vcodec copy -acodec aac -strict -2 -ac 2 -bsf:a aac_adtstoasc -flags +global_header' 206 | 207 | cmd_list = [ 208 | tmp_input, 209 | tmp_encode, 210 | tmp_out_rtmp 211 | ] 212 | 213 | tmp_should_record = None 214 | if subscribe_obj: 215 | tmp_should_record = subscribe_obj.get('is_should_record', None) 216 | if tmp_should_record == None: 217 | if utitls.configJson().get('is_auto_record', False): 218 | tmp_should_record = True 219 | 220 | if tmp_should_record == True: 221 | cmd_list.append('-vcodec copy -acodec aac -strict -2 -ac 2 -bsf:a aac_adtstoasc') 222 | cmd_list.append(tmp_out_file) 223 | 224 | cmd = '' 225 | for val in cmd_list: 226 | cmd += val + ' ' 227 | cmd = cmd.strip() #strip the last ' ' 228 | 229 | out, err, errcode = __runCMDSync(cmd) 230 | try: 231 | if os.path.exists(recordFilePath): 232 | # move the video to archive floder and change container to mp4 233 | archive_path = os.path.join(os.getcwd(), 'archive_videos', recordFileName_nosuffix + '.mp4') 234 | __runCMDSync('ffmpeg -loglevel error -i {} -codec copy -y {}'.format(recordFilePath, archive_path)) 235 | os.remove(recordFilePath) 236 | except Exception: 237 | utitls.myLogger(traceback.format_exc()) 238 | return out, err, errcode 239 | -------------------------------------------------------------------------------- /utitls.py: -------------------------------------------------------------------------------- 1 | import hmac 2 | import hashlib 3 | import secrets 4 | import json 5 | import datetime 6 | import traceback 7 | import re 8 | import signal, psutil 9 | import threading 10 | 11 | K_MANUAL_JSON_PATH = 'manualRestream.json' 12 | K_CONFIG_JSON_PATH = 'config.json' 13 | k_LOG_PATH = 'mainLog.log' 14 | 15 | 16 | def myLogger(logStr): 17 | resStr = str(datetime.datetime.now()) + " [MyLOGGER] " + str(logStr) 18 | try: 19 | print(resStr) 20 | except Exception as e: 21 | print(e) 22 | with open(k_LOG_PATH, 'a+', encoding='utf-8') as tmpFile: 23 | tmpFile.write(resStr + '\n') 24 | 25 | def verifySecert(verifyMsg, i_msg): 26 | i_msg = str.encode(i_msg) if isinstance(i_msg, str) else i_msg 27 | key = configJson().get('subSecert', '') 28 | key = str.encode(key) 29 | hexdig = hmac.new(key, msg=i_msg, digestmod=hashlib.sha1).hexdigest() 30 | 31 | print(verifyMsg, hexdig) 32 | return verifyMsg == hexdig 33 | 34 | def remove_emoji(text): 35 | emoji_pattern = re.compile("[" 36 | u"\U0001F600-\U0001F64F" # emoticons 37 | u"\U0001F300-\U0001F5FF" # symbols & pictographs 38 | u"\U0001F680-\U0001F6FF" # transport & map symbols 39 | u"\U0001F1E0-\U0001F1FF" # flags (iOS) 40 | u"\U00002700-\U000027BF" # Dingbats 41 | "]+", flags=re.UNICODE) 42 | return emoji_pattern.sub(r'', text) 43 | 44 | def kill_child_processes(parent_pid, sig=signal.SIGTERM): 45 | try: 46 | parent = psutil.Process(parent_pid) 47 | except psutil.NoSuchProcess: 48 | return 49 | children = parent.children(recursive=True) 50 | for process in children: 51 | process.send_signal(sig) 52 | 53 | def checkIsSupportForwardLink(forwardLink): 54 | check_list = [ 55 | '.m3u8', 56 | 'twitcasting.tv/', 57 | 'youtube.com/', 'youtu.be/', 58 | 'twitch.tv/', 59 | 'showroom-live.com/', 60 | 'openrec.tv/' 61 | ] 62 | for word in check_list: 63 | if word in forwardLink: 64 | return True 65 | return False 66 | 67 | def configJson(): 68 | with open(K_CONFIG_JSON_PATH, 'r', encoding='utf-8') as f: 69 | configDict = json.loads(f.read()) 70 | 71 | # greate the secerts key 72 | if configDict.get('subSecert') == "": 73 | configDict['subSecert'] = secrets.token_hex(16) 74 | saveConfigJson(configDict) 75 | return configDict 76 | 77 | 78 | def getSubInfosWithSubChannelId(channelId): 79 | ret_list = [] 80 | confDict = configJson() 81 | for subscribe in confDict.get('subscribeList', []): 82 | if channelId in subscribe.get('youtubeChannelId', "").split(','): 83 | if channelId != "": 84 | ret_list.append(subscribe) 85 | return ret_list 86 | 87 | def getSubInfosWithSubTwitterId(twitterId): 88 | ret_list = [] 89 | confDict = configJson() 90 | for subscribe in confDict.get('subscribeList', []): 91 | if twitterId in subscribe.get('twitterId', "").split(','): 92 | if twitterId != "": 93 | ret_list.append(subscribe) 94 | return ret_list 95 | 96 | def getSubWithKey(key, val): 97 | ret = None 98 | for subscribe in configJson().get('subscribeList', []): 99 | if subscribe.get(key) == val: 100 | ret = subscribe 101 | break 102 | return ret 103 | 104 | 105 | 106 | def setSubInfoWithKey(key, val, subDict): 107 | confDict = configJson() 108 | for subscribe in confDict.get('subscribeList', []): 109 | if subscribe.get(key) == val: 110 | subscribe.update(subDict) 111 | saveConfigJson(confDict) 112 | return 113 | 114 | 115 | def saveConfigJson(config_dict): 116 | with open(K_CONFIG_JSON_PATH, 'w', encoding='utf-8') as wf: 117 | json.dump(config_dict, wf, indent=4, sort_keys=True) 118 | 119 | 120 | def addManualSrc(srcNote, srcLink): 121 | tmp_dict = manualJson() 122 | src_dict = tmp_dict.get('src_dict', {}) 123 | src_dict[srcNote] = srcLink 124 | tmp_dict['src_dict'] = src_dict 125 | saveManualJson(tmp_dict) 126 | 127 | def addManualDes(desNote, desLink): 128 | tmp_dict = manualJson() 129 | des_dict = tmp_dict.get('des_dict', {}) 130 | des_dict[desNote] = desLink 131 | tmp_dict['des_dict'] = des_dict 132 | saveManualJson(tmp_dict) 133 | 134 | 135 | def manualJson(): 136 | manualDict = {"src_dict":{}, "des_dict":{}} 137 | try: 138 | with open(K_MANUAL_JSON_PATH, 'r', encoding='utf-8') as f: 139 | manualDict = json.loads(f.read()) 140 | except FileNotFoundError: 141 | saveManualJson(manualDict) 142 | return manualDict 143 | 144 | def saveManualJson(manualDict): 145 | with open(K_MANUAL_JSON_PATH, 'w', encoding='utf-8') as wf: 146 | json.dump(manualDict, wf, indent=4, sort_keys=True) 147 | 148 | 149 | def runFuncAsyncThread(target_func, args): 150 | try: 151 | t = threading.Thread(target=target_func, args=args) 152 | t.start() 153 | except Exception as e: 154 | myLogger(traceback.format_exc()) 155 | myLogger(str(e)) 156 | -------------------------------------------------------------------------------- /web/requests.js: -------------------------------------------------------------------------------- 1 | function setCookie(name,value,days) { 2 | var expires = ""; 3 | if (days) { 4 | var date = new Date(); 5 | date.setTime(date.getTime() + (days*24*60*60*1000)); 6 | expires = "; expires=" + date.toUTCString(); 7 | } 8 | document.cookie = name + "=" + (value || "") + expires + "; path=/"; 9 | } 10 | function getCookie(name) { 11 | var nameEQ = name + "="; 12 | var ca = document.cookie.split(';'); 13 | for(var i=0;i < ca.length;i++) { 14 | var c = ca[i]; 15 | while (c.charAt(0)==' ') c = c.substring(1,c.length); 16 | if (c.indexOf(nameEQ) == 0) return c.substring(nameEQ.length,c.length); 17 | } 18 | return null; 19 | } 20 | function _disableBtnWithId(id, time){ 21 | tmp_btn = document.getElementById(id); 22 | if (tmp_btn) { 23 | tmp_btn.disabled = true; 24 | setTimeout(function(){document.getElementById(id).disabled = false;}, time); 25 | } 26 | } 27 | function _disableAllBtn() { 28 | _disableBtnWithId("requestReStreamBtn", 3000); 29 | _disableBtnWithId("requestQuestListBtn", 3000); 30 | _disableBtnWithId("requestKillQuestBtn", 3000); 31 | _disableBtnWithId("refreshBTitleBtn", 10000); 32 | _disableBtnWithId("sendDynamicBtn", 10000); 33 | _disableBtnWithId("requestRefreshRTMPBtn", 10000); 34 | _disableBtnWithId("requestKillBRTMPBtn", 10000); 35 | } 36 | function _requestWithURL(url, res) { 37 | var xhttp = new XMLHttpRequest(); 38 | xhttp.onreadystatechange = function() { 39 | if (this.readyState == 4 && this.status == 200) { 40 | res_json = JSON.parse(this.responseText); 41 | res(res_json); 42 | } 43 | }; 44 | xhttp.open("GET", url, true); 45 | xhttp.send(); 46 | } 47 | 48 | function _selectAcc(cb) { 49 | alert("此操作需要较长时间,操作后请等待30秒左右"); 50 | var options_list = document.getElementById('SelectAcc').options; 51 | var tmp_list = []; 52 | for (var i = 1; i < options_list.length; i++) { 53 | var option = { 54 | text: options_list[i].text, 55 | value: options_list[i].value 56 | }; 57 | tmp_list.push(option); 58 | } 59 | bootbox.prompt({ 60 | title: "请选择操作的账号", 61 | inputType: 'select', 62 | inputOptions: tmp_list, 63 | callback: function (acc) { 64 | if (acc == null) { return; } 65 | bootbox.prompt({ 66 | title: "请输入操作码", 67 | value: getCookie(acc), 68 | callback: function(opt_code){ 69 | if (opt_code != null) { setCookie(acc, opt_code); } 70 | cb(acc, opt_code); 71 | } 72 | }); 73 | } 74 | }); 75 | } 76 | 77 | function requestReStream() { 78 | _disableAllBtn(); 79 | var tmp_forwardLink = document.getElementById("forwardLink").value; 80 | var tmp_restreamRtmpLink = document.getElementById("restreamRtmpLink").value; 81 | if (tmp_restreamRtmpLink != "" && tmp_forwardLink != "") { 82 | var tmp_requestURL = "../live_restream?forwardLink=" + encodeURIComponent(tmp_forwardLink) + "&restreamRtmpLink=" + encodeURIComponent(tmp_restreamRtmpLink); 83 | _requestWithURL(tmp_requestURL, function(res_json){ 84 | var tmp_responseMessageElement = document.getElementById("responseMessage"); 85 | tmp_responseMessageElement.innerHTML = ""; 86 | tmp_responseMessageElement.innerHTML += "请求返回码(为0或者1时说明当前任务已经添加成功):" + res_json.code + '\n'; 87 | tmp_responseMessageElement.innerHTML += res_json.msg; 88 | }) 89 | } 90 | } 91 | function requestQuestList() { 92 | _disableAllBtn(); 93 | var tmp_requestURL = "../questlist"; 94 | _requestWithURL(tmp_requestURL, function(res_json){ 95 | var tmp_responseMessageElement = document.getElementById("responseMessage"); 96 | var tmp_retStr = "任务列表为:\n"; 97 | res_json.forEach(function(item){ 98 | tmp_retStr += "------------------\n"; 99 | for(var key in item) { 100 | tmp_retStr += key + " -> " + item[key] + '\n'; 101 | } 102 | }); 103 | tmp_responseMessageElement.innerHTML = tmp_retStr; 104 | }) 105 | } 106 | function requestKillQuest() { 107 | bootbox.prompt({ 108 | title:"请输入需要关闭的RTMP流", 109 | value:"rtmp://XXXXXXXXXXXXX", 110 | callback: function(tmp_restreamRtmpLink) { 111 | if (tmp_restreamRtmpLink == null || "rtmp://XXXXXXXXXXXXX" == tmp_restreamRtmpLink){ return; } 112 | _disableAllBtn(); 113 | var tmp_requestURL = "../kill_quest?rtmpLink=" + encodeURIComponent(tmp_restreamRtmpLink); 114 | _requestWithURL(tmp_requestURL, function(res_json){ 115 | var tmp_responseMessageElement = document.getElementById("responseMessage"); 116 | tmp_responseMessageElement.innerHTML = JSON.stringify(res_json); 117 | }); 118 | } 119 | }); 120 | } 121 | function requestChangeBTitle(){ 122 | _selectAcc(function(acc, opt_code){ 123 | bootbox.prompt("正在操作{" + acc + "},请输入直播间标题。\n如果不需要更改,请点击“取消”", function(b_title){ 124 | if (b_title == null) { return; } 125 | bootbox.confirm("是否确认更改为:" + b_title, function(ret){ 126 | if (ret == false) { return; } 127 | _disableAllBtn(); 128 | var tmp_requestURL = "../bilibili_opt?changeTitle=" + encodeURIComponent(b_title) 129 | + "&acc=" + encodeURIComponent(acc) 130 | + "&opt_code=" + encodeURIComponent(opt_code); 131 | _requestWithURL(tmp_requestURL, function(res_json){ 132 | var tmp_responseMessageElement = document.getElementById("responseMessage"); 133 | tmp_responseMessageElement.innerHTML = JSON.stringify(res_json); 134 | }); 135 | }); 136 | }); 137 | }); 138 | } 139 | function requestSendDynamic(){ 140 | _selectAcc(function(acc, opt_code){ 141 | bootbox.prompt("正在操作{" + acc + "},请输入发送的动态。\n如果不需要发送,请点击“取消”", function(tmp_dynamic){ 142 | if (tmp_dynamic == null) { return; } 143 | bootbox.confirm("是否确认发送动态:" + tmp_dynamic, function(ret){ 144 | if (ret == false) { return; } 145 | _disableAllBtn(); 146 | var tmp_requestURL = "../bilibili_opt?sendDynamic=" + encodeURIComponent(tmp_dynamic) 147 | + "&acc=" + encodeURIComponent(acc) 148 | + "&opt_code=" + encodeURIComponent(opt_code); 149 | _requestWithURL(tmp_requestURL, function(res_json){ 150 | var tmp_responseMessageElement = document.getElementById("responseMessage"); 151 | tmp_responseMessageElement.innerHTML = JSON.stringify(res_json); 152 | }); 153 | }); 154 | }); 155 | }); 156 | } 157 | function requestRefreshRTMP(){ 158 | _selectAcc(function(acc, opt_code){ 159 | bootbox.confirm("是否确认刷新RTMP流?(以服务器的IP重新开播一次。只有在使用直播台开播了之后,不小心进行了b站直播后台才需要进行些操作。操作的人最好清楚这是在做什么)", function(ret){ 160 | if (ret == false) { return; } 161 | _disableAllBtn(); 162 | var tmp_requestURL = "../bilibili_opt?refreshRTMP=1" 163 | + "&acc=" + encodeURIComponent(acc) 164 | + "&opt_code=" + encodeURIComponent(opt_code); 165 | _requestWithURL(tmp_requestURL, function(res_json){ 166 | var tmp_responseMessageElement = document.getElementById("responseMessage"); 167 | tmp_responseMessageElement.innerHTML = JSON.stringify(res_json); 168 | }); 169 | }); 170 | }); 171 | } 172 | function requestKillBRTMP() { 173 | _selectAcc(function(acc, opt_code){ 174 | bootbox.confirm("是否确认关闭当前B站任务RTMP流?(关闭转播任务,设计用于撞车时切换转播源或者某些原因需要关闭转播任务))", function(ret){ 175 | if (ret == false) { return; } 176 | _disableAllBtn(); 177 | var tmp_requestURL = "../bilibili_opt?killRTMP=1" 178 | + "&acc=" + encodeURIComponent(acc) 179 | + "&opt_code=" + encodeURIComponent(opt_code); 180 | _requestWithURL(tmp_requestURL, function(res_json){ 181 | var tmp_responseMessageElement = document.getElementById("responseMessage"); 182 | tmp_responseMessageElement.innerHTML = JSON.stringify(res_json); 183 | }); 184 | }); 185 | }); 186 | } 187 | 188 | function getManualJson() { 189 | var tmp_requestURL = "../get_manual_json" 190 | _requestWithURL(tmp_requestURL, function(res_json){ 191 | srcDict = res_json['src_dict'] 192 | addList = res_json['acc_mark_list'] 193 | var selectSrc = document.getElementById("SelectSrc"); 194 | var selectDes = document.getElementById("SelectAcc"); 195 | for(var key in srcDict) { 196 | var option = document.createElement("option"); 197 | option.text = key; 198 | option.value = srcDict[key]; 199 | selectSrc.add(option); 200 | } 201 | for(var i in addList) { 202 | var option = document.createElement("option"); 203 | option.text = addList[i]; 204 | option.value = addList[i]; 205 | selectDes.add(option); 206 | } 207 | }); 208 | } 209 | 210 | function addRestreamSrc(){ 211 | var tmp_dummy_01 = "例:神乐mea_Youtube"; 212 | var tmp_dummy_02 = "例:https://www.youtube.com/channel/XXX/live"; 213 | bootbox.prompt({ 214 | title: "请输入转播源的备注名字", 215 | value: tmp_dummy_01, 216 | callback: function(tmp_srcNote) { 217 | if (tmp_srcNote == null || tmp_srcNote == tmp_dummy_01) {return;} 218 | bootbox.prompt({ 219 | title: "请输入转播源的地址", 220 | value: tmp_dummy_02, 221 | callback: function(tmp_srcLink) { 222 | if (tmp_srcLink == null || tmp_srcLink == tmp_dummy_02) {return;} 223 | bootbox.confirm({ 224 | title: "请确认添加信息是否正确", 225 | message: "

备注名字:\n" + tmp_srcNote + "\n转播源的地址:\n" + tmp_srcLink + "

", 226 | callback: function(is_ok) { 227 | if (is_ok == false) {return;} 228 | _disableAllBtn(); 229 | var tmp_requestURL = "../addRestreamSrc?srcNote=" + encodeURIComponent(tmp_srcNote) + "&srcLink=" + encodeURIComponent(tmp_srcLink); 230 | _requestWithURL(tmp_requestURL, function(res_json){ 231 | location.reload(); 232 | }); 233 | } 234 | }); 235 | } 236 | }); 237 | } 238 | }); 239 | } 240 | 241 | function onSelectSrc() { 242 | var val = document.getElementById("SelectSrc").value; 243 | document.getElementById("forwardLink").value = val; 244 | } 245 | 246 | function onSelectDes() { 247 | var val = document.getElementById("SelectDes").value; 248 | var tb = document.getElementById("restreamRtmpLink"); 249 | tb.value = val; 250 | // tb.setAttribute("onmousedown", 'return false;'); 251 | // tb.setAttribute("onselectstart", 'return false;'); 252 | } 253 | 254 | function onSelectAcc() { 255 | var tmp_dummy_01 = '请输入操作码'; 256 | alert("请牢记,开播后 !一定不能!进入B站直播中心页面操作"); 257 | alert("如果当前账号是正在直播的状态,开播会覆盖当前任务.\n(同来源且同直播间不会覆盖)"); 258 | var val = document.getElementById("SelectAcc").value; 259 | 260 | var tmp_last_opt = getCookie(val); 261 | if (tmp_last_opt != null) { tmp_dummy_01 = tmp_last_opt; } 262 | var bpwd = prompt("请输入转播账号{" + val + "}操作码\n(操作码会记录在本地浏览器中,点击'取消'中断后续操作)", tmp_dummy_01); 263 | if (bpwd == null) { 264 | document.getElementById("SelectAcc")[0].selected = 'selected'; 265 | return; 266 | } 267 | setCookie(val, bpwd); 268 | 269 | var b_title = null; 270 | b_title = prompt("请输入直播间标题。\n如果不需要更改,请点击“取消”"); 271 | var is_send_dynamic = confirm("是否发送直播动态?"); 272 | is_send_dynamic = is_send_dynamic ? "1" : "0"; 273 | 274 | var dynamic_words = "开始直播了,转播中"; 275 | if (is_send_dynamic == true){ 276 | tmp_word = prompt("请输入动态内容,以\\n做分行\n例:'这是\\n分行'\n下面已经填入了默认内容,最终发送时会自动附带直播间地址。", dynamic_words); 277 | if (tmp_word != null) { dynamic_words = tmp_word; } 278 | } 279 | 280 | var is_record = confirm("是否同时进行录像?"); 281 | is_record = is_record ? "1" : "0"; 282 | 283 | if ((bpwd) && (bpwd != '请输入操作码')){ 284 | var tb = document.getElementById("restreamRtmpLink"); 285 | tb.value = "ACCMARK=" + val 286 | + "&" + "OPTC=" + bpwd 287 | + "&" + "SEND_DYNAMIC=" + is_send_dynamic 288 | + "&" + "DYNAMIC_WORDS=" + dynamic_words 289 | + "&" + "IS_SHOULD_RECORD=" + is_record; 290 | if (b_title) { 291 | tb.value += "&" + "B_TITLE=" + b_title; 292 | } 293 | 294 | tb.setAttribute("onmousedown", 'return false;'); 295 | tb.setAttribute("onselectstart", 'return false;'); 296 | 297 | alert("!!!!注意!!!!!!\n此种账号开播方式开播后!一定!不要进入B站直播中心页面操作!!\n"); 298 | alert("如果必需进入B站直播管理页面操作,请先操作B站直播管理页面后再从这里开播。\n\n否则会导致RTMP流中断,导致新进直播间的人不能观看,并且不能断开和恢复!!!!!!!" 299 | + "\n\n如果需要关闭直播间请自动进入b站后台进行关闭."); 300 | alert("请牢记,开播后 !一定不能!进入B站直播中心页面操作"); 301 | } else { 302 | document.getElementById("SelectAcc")[0].selected = 'selected'; 303 | } 304 | } 305 | 306 | getManualJson(); 307 | -------------------------------------------------------------------------------- /web/restream.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 手动转播台 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 | 19 |
手动转播台 v0.10.1
20 |
21 | 22 | 23 | 24 | 25 | 26 | 40 | 41 |
42 |
43 | 44 | 推流地址例子: 45 | B站的地址是两串合起来的,像youtube那些也是path+key,两串合起来 46 | rtmp://XXXXXX.acg.tv/XXXX/?streamname=live_XXXXXXX&key=XXXXXXXXXX 47 |
48 | 注意:有些rtmp的path结尾是没有'/'的,如果rtmp的地址结尾不是'/',需要自己手动添加 49 | 50 |
51 |
52 | 53 | 54 |
55 | 56 | 57 | 58 | 59 | 60 |

61 | 62 |
63 | 64 | 67 |
68 | 69 |
70 | 71 | 74 |
75 | 76 |
77 | 78 |
79 | 80 | 81 |
82 |
83 | 84 | 85 |
86 | 87 |
88 | 89 | 90 | 91 | 92 |
93 | 94 |
95 |

96 |

97 | 98 |
99 |
100 | 101 | 102 | 103 | --------------------------------------------------------------------------------