comments.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707
  1. # scraping comments
  2. import datetime
  3. import json
  4. import os
  5. import threading
  6. import time
  7. # Type “pip install websocket-client” to install.
  8. import websocket # this is to record comments on real time
  9. import math
  10. import logging
  11. from json import JSONDecodeError
  12. from websocket import ABNF
  13. from websocket import WebSocketConnectionClosedException
  14. from showroom.constants import TOKYO_TZ, FULL_DATE_FMT
  15. from showroom.utils import format_name
  16. from requests.exceptions import HTTPError
  17. # TODO: save comments, stats, telop(s)
  18. # {
  19. # "comment_log": [],
  20. # "telop": {
  21. # "latest": {
  22. # "text": "",
  23. # "created_at": ""
  24. # },
  25. # "older": [
  26. # {
  27. # "text": "",
  28. # "created_at": ""
  29. # }
  30. # ]
  31. # },
  32. # "live_info": {
  33. # # stuff like view count over time etc.
  34. # }
  35. # }
  36. '''
  37. Option 1:
  38. 2 separate "loggers", one for comments, one for stats/telop
  39. The *only* reason to do this is to allow grabbing just stats and telop instead of all three.
  40. So I'm not going to do that. What's option 2.
  41. Options 2:
  42. StatsLogger, CommentsLogger, RoomLogger:
  43. StatsLogger records just stats and telop
  44. '''
  45. cmt_logger = logging.getLogger('showroom.comments')
  46. def convert_comments_to_danmaku(startTime, commentList,
  47. fontsize=18, fontname='MS PGothic', alpha='1A',
  48. width=640, height=360):
  49. """
  50. Convert comments to danmaku (弾幕 / bullets) subtitles
  51. :param startTime: comments recording start time (timestamp in milliseconds)
  52. :param commentList: list of showroom messages
  53. :param fontsize = 18
  54. :param fontname = 'MS PGothic'
  55. :param alpha = '1A' # transparency '00' to 'FF' (hex string)
  56. :param width = 640 # video screen height
  57. :param height = 360 # video screen width
  58. :return a string of danmaku subtitles
  59. """
  60. # slotsNum: max number of comment line vertically shown on screen
  61. slotsNum = math.floor(height / fontsize)
  62. travelTime = 8 * 1000 # 8 sec, bullet comment flight time on screen
  63. # ass subtitle file header
  64. danmaku = "[Script Info]\n"
  65. danmaku += "ScriptType: v4.00+\n"
  66. danmaku += "Collisions: Normal\n"
  67. danmaku += "PlayResX: " + str(width) + "\n"
  68. danmaku += "PlayResY: " + str(height) + "\n\n"
  69. danmaku += "[V4+ Styles]\n"
  70. danmaku += "Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\n"
  71. danmaku += "Style: danmakuFont, " + fontname + ", " + str(fontsize) + \
  72. ", &H00FFFFFF, &H00FFFFFF, &H00000000, &H00000000, 1, 0, 0, 0, 100, 100, 0.00, 0.00, 1, 1, 0, 2, 20, 20, 20, 0\n\n"
  73. danmaku += "[Events]\n"
  74. danmaku += "Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n"
  75. # each comment line on screen can be seen as a slot
  76. # each slot will be filled with the time which indicates when the bullet comment will disappear on screen
  77. # slot[0], slot[1], slot[2], ...: for the comment lines from top to down
  78. slots = []
  79. for i in range(slotsNum):
  80. slots.append(0)
  81. previousTelop = ''
  82. for data in commentList:
  83. m_type = str(data['t'])
  84. comment = ''
  85. if m_type == '1': # comment
  86. comment = data['cm']
  87. elif m_type == '3': # voting start
  88. poll = data['l']
  89. if len(poll) < 1:
  90. continue
  91. comment = 'Poll Started: 【({})'.format(poll[0]['id'] % 10000)
  92. for k in range(1, len(poll)):
  93. if k > 4:
  94. comment += ', ...'
  95. break
  96. comment += ', ({})'.format(poll[k]['id'] % 10000)
  97. comment += '】'
  98. elif m_type == '4': # voting result
  99. poll = data['l']
  100. if len(poll) < 1:
  101. continue
  102. comment = 'Poll: 【({}) {}%'.format(poll[0]['id'] % 10000, poll[0]['r'])
  103. for k in range(1, len(poll)):
  104. if k > 4:
  105. comment += ', ...'
  106. break
  107. comment += ', ({}) {}%'.format(poll[k]['id'] % 10000, poll[k]['r'])
  108. comment += '】'
  109. elif m_type == '8': # telop
  110. telop = data['telop']
  111. if telop is not None and telop != previousTelop:
  112. previousTelop = telop
  113. # show telop as a comment
  114. comment = 'Telop: 【' + telop + '】'
  115. else:
  116. continue
  117. else: # not comment, telop, or voting result
  118. continue
  119. # compute current relative time
  120. t = data['received_at'] - startTime
  121. # find available slot vertically from up to down
  122. selectedSlot = 0
  123. isSlotFound = False
  124. for j in range(slotsNum):
  125. if slots[j] <= t:
  126. slots[j] = t + travelTime # replaced with the time that it will finish
  127. isSlotFound = True
  128. selectedSlot = j
  129. break
  130. # when all slots have larger times, find the smallest time and replace the slot
  131. if not isSlotFound:
  132. minIdx = 0
  133. for j in range(1, slotsNum):
  134. if slots[j] < slots[minIdx]:
  135. minIdx = j
  136. slots[minIdx] = t + travelTime
  137. selectedSlot = minIdx
  138. # calculate bullet comment flight positions, from (x1,y1) to (x2,y2) on screen
  139. # extra flight length so a comment appears and disappears outside of the screen
  140. extraLen = math.ceil(len(comment) / 2.0)
  141. x1 = width + extraLen * fontsize
  142. y1 = (selectedSlot + 1) * fontsize
  143. x2 = 0 - extraLen * fontsize
  144. y2 = y1
  145. def msecToAssTime(uTime):
  146. """ convert milliseconds to ass subtitle format """
  147. msec = uTime % 1000
  148. msec = int(round(msec / 10.0))
  149. uTime = math.floor(uTime / 1000.0)
  150. s = int(uTime % 60)
  151. uTime = math.floor(uTime / 60.0)
  152. m = int(uTime % 60)
  153. h = int(math.floor(uTime / 60.0))
  154. msf = ("00" + str(msec))[-2:]
  155. sf = ("00" + str(s))[-2:]
  156. mf = ("00" + str(m))[-2:]
  157. hf = ("00" + str(h))[-2:]
  158. return hf + ":" + mf + ":" + sf + "." + msf
  159. # build ass subtitle script
  160. sub = "Dialogue: 3," + msecToAssTime(t) + "," + msecToAssTime(t + travelTime)
  161. # alpha: 00 means fully visible, and FF (ie. 255 in decimal) is fully transparent.
  162. sub += ",danmakuFont,,0000,0000,0000,,{\\alpha&H" + alpha + "&\\move("
  163. sub += str(x1) + "," + str(y1) + "," + str(x2) + "," + str(y2)
  164. sub += ")}" + comment + "\n"
  165. danmaku += sub
  166. # end of for
  167. return danmaku
  168. class CommentLogger(object):
  169. comment_id_pattern = "{created_at}_{user_id}"
  170. def __init__(self, room, client, settings, watcher):
  171. self.room = room
  172. self.client = client
  173. self.settings = settings
  174. self.watcher = watcher
  175. self.last_update = datetime.datetime.fromtimestamp(10000, tz=TOKYO_TZ)
  176. self.update_interval = self.settings.comments.default_update_interval
  177. self.comment_log = []
  178. self.comment_ids = set()
  179. self._thread = None
  180. self.comment_count = 0
  181. self.ws = None
  182. self.ws_startTime = 0
  183. self.ws_send_txt = ''
  184. self._thread_interval = None
  185. self._isQuit = False
  186. self._isRecording = False
  187. @property
  188. def isRecording(self):
  189. return self._isRecording
  190. def start(self):
  191. if not self._thread:
  192. self._thread = threading.Thread(target=self.run, name='{} Comment Log'.format(self.room.name))
  193. self._thread.start()
  194. def run(self):
  195. """
  196. Record comments and save as niconico danmaku (弾幕 / bullets) subtitle ass file
  197. """
  198. def ws_on_message(ws, message):
  199. """ WebSocket callback """
  200. # "created at" has no millisecond part, so we record the precise time here
  201. now = int(time.time() * 1000)
  202. idx = message.find("{")
  203. if idx < 0:
  204. cmt_logger.error('no JSON message - {}'.format(message))
  205. return
  206. message = message[idx:]
  207. try:
  208. data = json.loads(message)
  209. except JSONDecodeError as e:
  210. # cmt_logger.debug('JSONDecodeError, broken message: {}'.format(message))
  211. # try to fix
  212. message += '","t":"1"}'
  213. try:
  214. data = json.loads(message)
  215. except JSONDecodeError:
  216. cmt_logger.error('JSONDecodeError, failed to fix broken message: {}'.format(message))
  217. return
  218. cmt_logger.debug('broken message, JSONDecodeError is fixed: {}'.format(message))
  219. # add current time
  220. data['received_at'] = now
  221. # Some useful info in the message:
  222. # ['t'] message type, determine the message is comment, telop, or gift
  223. # ['cm'] comment
  224. # ['ac'] name
  225. # ['u'] user_id
  226. # ['av'] avatar_id
  227. # ['g'] gift_id
  228. # ['n'] gift_num
  229. # type of the message
  230. m_type = str(data['t']) # could be integer or string
  231. if m_type == '1': # comment
  232. comment = data['cm']
  233. # skip counting for 50
  234. if len(comment) < 3 and comment.isdecimal() and int(comment) <= 50:
  235. # s1 = '⑷'; s2 = u'²'; s3 = '❹'
  236. # print(s1.isdigit()) # True
  237. # print(s2.isdigit()) # True
  238. # print(s1.isdecimal()) # False
  239. # print(s2.isdecimal()) # False
  240. # int(s1) # ValueError
  241. # int(s2) # ValueError
  242. pass
  243. else:
  244. comment = comment.replace('\n', ' ') # replace line break to a space
  245. # cmt_logger.info('{}: {}'.format(self.room.name, comment))
  246. data['cm'] = comment
  247. self.comment_log.append(data)
  248. self.comment_count += 1
  249. elif m_type == '2': # gift
  250. pass
  251. elif m_type == '3': # voting start
  252. self.comment_log.append(data)
  253. elif m_type == '4': # voting result
  254. self.comment_log.append(data)
  255. cmt_logger.debug('{}: has voting result'.format(self.room.name))
  256. elif m_type == '8': # telop
  257. self.comment_log.append(data)
  258. if data['telop'] is not None: # could be null
  259. # cmt_logger.info('{}: telop = {}'.format(self.room.name, data['telop']))
  260. pass
  261. elif m_type == '11': # cumulated gifts report
  262. pass
  263. elif m_type == '101': # indicating live finished
  264. self.comment_log.append(data)
  265. self._isQuit = True
  266. else:
  267. self.comment_log.append(data)
  268. def ws_on_error(ws, error):
  269. """ WebSocket callback """
  270. cmt_logger.error('websocket on error: {} - {}'.format(type(error).__name__, error))
  271. def ws_on_close(ws):
  272. """ WebSocket callback """
  273. # cmt_logger.debug('websocket closed')
  274. self._isQuit = True
  275. def interval_send(ws):
  276. """
  277. interval thread to send message and to close WebSocket
  278. """
  279. count = 60
  280. while True:
  281. # check whether to quit every sec
  282. if self._isQuit:
  283. break
  284. # send bcsvr_key every 60 secs
  285. if count >= 60:
  286. count = 0
  287. try:
  288. # cmt_logger.debug('sending {}'.format(self.ws_send_txt))
  289. ws.send(self.ws_send_txt)
  290. except WebSocketConnectionClosedException as e:
  291. cmt_logger.debug(
  292. 'WebSocket closed before sending message. {} Closing interval thread now...'.format(e))
  293. break
  294. time.sleep(1)
  295. count += 1
  296. # close WebSocket
  297. if ws is not None:
  298. ws.close()
  299. ws = None
  300. # cmt_logger.debug('interval thread finished')
  301. def ws_on_open(ws):
  302. """ WebSocket callback """
  303. self.ws_startTime = int(time.time() * 1000)
  304. # cmt_logger.debug('websocket on open')
  305. # keep sending bcsvr_key to prevent disconnection
  306. self._thread_interval = threading.Thread(target=interval_send,
  307. name='{} Comment Log interval'.format(self.room.name), args=(ws,))
  308. self._thread_interval.start()
  309. def ws_start(ws_uri, on_open=ws_on_open, on_message=ws_on_message,
  310. on_error=ws_on_error, on_close=ws_on_close):
  311. """ WebSocket main loop """
  312. self.ws = websocket.WebSocket()
  313. # connect
  314. try:
  315. self.ws.connect(ws_uri)
  316. except Exception as e:
  317. on_error(self.ws, e)
  318. return
  319. on_open(self.ws)
  320. buffer = b""
  321. buffered_opcode = ABNF.OPCODE_TEXT
  322. while not self._isQuit:
  323. try:
  324. frame = self.ws.recv_frame()
  325. except WebSocketConnectionClosedException as e:
  326. cmt_logger.debug('ws_start: WebSocket Closed')
  327. break
  328. except Exception as e:
  329. on_error(self.ws, e)
  330. break
  331. """
  332. Fragmented frame example: For a text message sent as three fragments,
  333. the 1st fragment: opcode = 0x1 (OPCODE_TEXT) and FIN bit = 0,
  334. the 2nd fragment: opcode = 0x0 (OPCODE_CONT) and FIN bit = 0,
  335. the last fragment: opcode = 0x0 (OPCODE_CONT) and FIN bit = 1.
  336. """
  337. if frame.opcode in (ABNF.OPCODE_TEXT, ABNF.OPCODE_BINARY, ABNF.OPCODE_CONT):
  338. buffer += frame.data
  339. if frame.opcode != ABNF.OPCODE_CONT:
  340. buffered_opcode = frame.opcode
  341. else:
  342. cmt_logger.debug('ws_start: fragment message: {}'.format(frame.data))
  343. # it's either a last fragmented frame, or a non-fragmented single message frame
  344. if frame.fin == 1:
  345. data = buffer
  346. buffer = b""
  347. if buffered_opcode == ABNF.OPCODE_TEXT:
  348. message = ""
  349. try:
  350. message = data.decode('utf-8')
  351. except UnicodeDecodeError as e:
  352. message = data.decode('latin-1')
  353. cmt_logger.debug('ws_start: UnicodeDecodeError, decoded as latin-1: {}'.format(message))
  354. except Exception as e:
  355. on_error(self.ws, e)
  356. on_message(self.ws, message)
  357. elif buffered_opcode == ABNF.OPCODE_BINARY:
  358. cmt_logger.debug('ws_start: received unknown binary data: {}'.format(data))
  359. elif frame.opcode == ABNF.OPCODE_CLOSE:
  360. # cmt_logger.debug('ws_start: received close opcode')
  361. # self.ws.close() will try to send close frame, so we skip sending close frame here
  362. break
  363. elif frame.opcode == ABNF.OPCODE_PING:
  364. cmt_logger.debug('ws_start: received ping, sending pong')
  365. if len(frame.data) < 126:
  366. self.ws.pong(frame.data)
  367. else:
  368. cmt_logger.debug('ws_start: ping message too big to send')
  369. elif frame.opcode == ABNF.OPCODE_PONG:
  370. cmt_logger.debug('ws_start: received pong')
  371. else:
  372. cmt_logger.error('ws_start: unknown frame opcode = {}'.format(frame.opcode))
  373. on_close(self.ws)
  374. self.ws.close()
  375. # Get live info from https://www.showroom-live.com/api/live/live_info?room_id=xxx
  376. # If a room closes and then reopen on live within 30 seconds (approximately),
  377. # the broadcast_key from https://www.showroom-live.com/api/live/onlives
  378. # will not be updated with the new key. It's the same situation that when a
  379. # room live is finished, /api/live/onlives will not update its onlives list within
  380. # about 30 seconds. So here it's better to get accurate broadcast_key
  381. # from /api/live/live_info
  382. try:
  383. info = self.client.live_info(self.room.room_id) or []
  384. except HTTPError as e:
  385. # TODO: log/handle properly
  386. cmt_logger.error('HTTP Error while getting live_info for {}: {}'.format(self.room.handle, e))
  387. return
  388. if len(info['bcsvr_key']) == 0:
  389. cmt_logger.debug('not on live, no bcsvr_key.')
  390. return
  391. # # TODO: allow comment_logger to trigger get_live_status ?
  392. # last_counts = []
  393. # max_interval = self.settings.comments.max_update_interval
  394. # min_interval = self.settings.comments.min_update_interval
  395. _, destdir, filename = format_name(self.settings.directory.data,
  396. self.watcher.start_time.strftime(FULL_DATE_FMT),
  397. self.room, ext=self.settings.ffmpeg.container)
  398. # TODO: modify format_name so it doesn't require so much hackery for this
  399. filename = filename.replace(self.settings.ffmpeg.container, ' comments.json')
  400. filenameAss = filename.replace(' comments.json', 'ass')
  401. destdir += '/comments'
  402. # TODO: only call this once per group per day
  403. os.makedirs(destdir, exist_ok=True)
  404. outfile = '/'.join((destdir, filename))
  405. outfileAss = '/'.join((destdir, filenameAss))
  406. # def add_counts(count):
  407. # return [count] + last_counts[:2]
  408. cmt_logger.info("Recording comments for {}".format(self.room.name))
  409. # while self.watcher.is_live():
  410. # count = 0
  411. # seen = 0
  412. # # update comments
  413. # try:
  414. # data = self.client.comment_log(self.room.room_id) or []
  415. # except HTTPError as e:
  416. # # TODO: log/handle properly
  417. # print('HTTP Error while getting comments for {}: {}'.format(self.room.handle, e))
  418. # break
  419. # for comment in data:
  420. # if len(comment['comment']) < 4 and comment['comment'].isdigit():
  421. # continue
  422. # cid = self.comment_id_pattern.format(**comment)
  423. # if cid not in self.comment_ids:
  424. # self.comment_log.append(comment)
  425. # self.comment_ids.add(cid)
  426. # count += 1
  427. # else:
  428. # seen += 1
  429. #
  430. # if seen > 5:
  431. # last_counts = add_counts(count)
  432. # break
  433. #
  434. # # update update_interval if needed
  435. # highest_count = max(last_counts, default=10)
  436. # if highest_count < 7 and self.update_interval < max_interval:
  437. # self.update_interval += 1.0
  438. # elif highest_count > 50 and self.update_interval > min_interval:
  439. # self.update_interval *= 0.5
  440. # elif highest_count > 20 and self.update_interval > min_interval:
  441. # self.update_interval -= 1.0
  442. #
  443. # current_time = datetime.datetime.now(tz=TOKYO_TZ)
  444. # timediff = (current_time - self.last_update).total_seconds()
  445. # self.last_update = current_time
  446. #
  447. # sleep_timer = max(0.5, self.update_interval - timediff)
  448. # time.sleep(sleep_timer)
  449. self._isRecording = True
  450. self.ws_send_txt = 'SUB\t' + info['bcsvr_key']
  451. websocket.enableTrace(False) # False: disable trace outputs
  452. ws_start('ws://' + info['bcsvr_host'] + ':' + str(info['bcsvr_port']),
  453. on_open=ws_on_open, on_message=ws_on_message,
  454. on_error=ws_on_error, on_close=ws_on_close)
  455. if self._thread_interval is not None:
  456. self._thread_interval.join()
  457. # sorting
  458. self.comment_log = sorted(self.comment_log, key=lambda x: x['received_at'])
  459. with open(outfile, 'w', encoding='utf8') as outfp:
  460. # json.dump({"comment_log": sorted(self.comment_log, key=lambda x: x['created_at'], reverse=True)},
  461. # outfp, indent=2, ensure_ascii=False)
  462. json.dump(self.comment_log, outfp, indent=2, ensure_ascii=False)
  463. if len(self.comment_log) > 0:
  464. # convert comments to danmaku
  465. assTxt = convert_comments_to_danmaku(self.ws_startTime, self.comment_log,
  466. fontsize=18, fontname='MS PGothic', alpha='1A',
  467. width=640, height=360)
  468. with open(outfileAss, 'w', encoding='utf8') as outfpAss:
  469. outfpAss.write(assTxt)
  470. cmt_logger.info('Completed {}'.format(outfileAss))
  471. else:
  472. cmt_logger.info('No comments to save for {}'.format(self.room.name))
  473. self._isRecording = False
  474. def quit(self):
  475. """
  476. To quit comment logger anytime (to close WebSocket, save file and finish job)
  477. """
  478. self._isQuit = True
  479. self._thread.join()
  480. if self._thread_interval is not None:
  481. self._thread_interval.join()
  482. class RoomScraper:
  483. comment_id_pattern = "{created_at}_{user_id}"
  484. def __init__(self, room, client, settings, watcher, record_comments=False):
  485. self.room = room
  486. self.client = client
  487. self.settings = settings
  488. self.watcher = watcher
  489. self.last_update = datetime.datetime.fromtimestamp(10000, tz=TOKYO_TZ)
  490. self.update_interval = self.settings.comments.default_update_interval
  491. self.comment_log = []
  492. self.comment_ids = set()
  493. self._thread = None
  494. self.record_comments = record_comments
  495. def start(self):
  496. if not self._thread:
  497. if self.record_comments:
  498. self._thread = threading.Thread(target=self.record_with_comments,
  499. name='{} Room Log'.format(self.room.name))
  500. else:
  501. self._thread = threading.Thread(target=self.record,
  502. name='{} Room Log'.format(self.room.name))
  503. self._thread.start()
  504. def _fetch_comments(self):
  505. pass
  506. def _parse_comments(self, comment_log):
  507. pass
  508. def _fetch_info(self):
  509. "https://www.showroom-live.com/room/get_live_data?room_id=76535"
  510. pass
  511. def _parse_info(self, info):
  512. result = {
  513. # TODO: check for differences between result and stored data
  514. # some of this stuff should never change and/or is useful in the Watcher
  515. "live_info": {
  516. "created_at": info['live_res'].get('created_at'),
  517. "started_at": info['live_res'].get('started_at'),
  518. "live_id": info['live_res'].get('live_id'),
  519. "comment_num": info['live_res'].get('comment_num'), # oooohhhhhh
  520. # "chat_token": info['live_res'].get('chat_token'),
  521. "hot_point": "",
  522. "gift_num": "",
  523. "live_type": "",
  524. "ended_at": "",
  525. "view_uu": "",
  526. "bcsvr_key": "",
  527. },
  528. "telop": info['telop'],
  529. "broadcast_key": "", # same as live_res.bcsvr_key
  530. "online_user_num": "", # same as live_res.view_uu
  531. "room": {
  532. "last_live_id": "",
  533. },
  534. "broadcast_port": 8080,
  535. "broadcast_host": "onlive.showroom-live.com",
  536. }
  537. pass
  538. def record_with_comments(self):
  539. # TODO: allow comment_logger to trigger get_live_status ?
  540. last_counts = []
  541. max_interval = self.settings.comments.max_update_interval
  542. min_interval = self.settings.comments.min_update_interval
  543. _, destdir, filename = format_name(self.settings.directory.data,
  544. self.watcher.start_time.strftime(FULL_DATE_FMT),
  545. self.room, self.settings.ffmpeg.container)
  546. # TODO: modify format_name so it doesn't require so much hackery for this
  547. filename = filename.replace('.{}'.format(self.settings.ffmpeg.container), ' comments.json')
  548. destdir += '/comments'
  549. # TODO: only call this once per group per day
  550. os.makedirs(destdir, exist_ok=True)
  551. outfile = '/'.join((destdir, filename))
  552. def add_counts(count):
  553. return [count] + last_counts[:2]
  554. print("Recording comments for {}".format(self.room.name))
  555. while self.watcher.is_live():
  556. count = 0
  557. seen = 0
  558. # update comments
  559. try:
  560. data = self.client.comment_log(self.room.room_id) or []
  561. except HTTPError as e:
  562. # TODO: log/handle properly
  563. print('HTTP Error while getting comments for {}: {}\n{}'.format(self.room.handle, e, e.response.content))
  564. break
  565. for comment in data:
  566. cid = self.comment_id_pattern.format(**comment)
  567. if cid not in self.comment_ids:
  568. self.comment_log.append(comment)
  569. self.comment_ids.add(cid)
  570. count += 1
  571. else:
  572. seen += 1
  573. if seen > 5:
  574. last_counts = add_counts(count)
  575. break
  576. # update update_interval if needed
  577. highest_count = max(last_counts, default=10)
  578. if highest_count < 7 and self.update_interval < max_interval:
  579. self.update_interval += 1.0
  580. elif highest_count > 50 and self.update_interval > min_interval:
  581. self.update_interval *= 0.5
  582. elif highest_count > 20 and self.update_interval > min_interval:
  583. self.update_interval -= 1.0
  584. current_time = datetime.datetime.now(tz=TOKYO_TZ)
  585. timediff = (current_time - self.last_update).total_seconds()
  586. self.last_update = current_time
  587. sleep_timer = max(0.5, self.update_interval - timediff)
  588. time.sleep(sleep_timer)
  589. with open(outfile, 'w', encoding='utf8') as outfp:
  590. json.dump({"comment_log": sorted(self.comment_log, key=lambda x: x['created_at'], reverse=True)},
  591. outfp, indent=2, ensure_ascii=False)
  592. def record(self):
  593. pass
  594. def join(self):
  595. pass