diff --git a/api/chat.py b/api/chat.py index 8095dce..9470b54 100644 --- a/api/chat.py +++ b/api/chat.py @@ -6,13 +6,16 @@ import json import logging import random import time +import uuid from typing import * import aiohttp import tornado.websocket import blivedm.blivedm as blivedm +import config import models.avatar +import models.translate logger = logging.getLogger(__name__) @@ -25,6 +28,7 @@ class Command(enum.IntEnum): ADD_MEMBER = 4 ADD_SUPER_CHAT = 5 DEL_SUPER_CHAT = 6 + UPDATE_TRANSLATION = 7 _http_session = aiohttp.ClientSession() @@ -94,6 +98,7 @@ class Room(blivedm.BLiveClient): def __init__(self, room_id): super().__init__(room_id, session=_http_session, heartbeat_interval=10) self.clients: List['ChatHandler'] = [] + self.auto_translate_count = 0 def stop_and_close(self): if self.is_running: @@ -110,6 +115,14 @@ class Room(blivedm.BLiveClient): except tornado.websocket.WebSocketClosedError: pass + def send_message_if(self, can_send_func: Callable[['ChatHandler'], bool], cmd, data): + body = json.dumps({'cmd': cmd, 'data': data}) + for client in filter(can_send_func, self.clients): + try: + client.write_message(body) + except tornado.websocket.WebSocketClosedError: + pass + async def _on_receive_danmaku(self, danmaku: blivedm.DanmakuMessage): asyncio.ensure_future(self.__on_receive_danmaku(danmaku)) @@ -122,6 +135,19 @@ class Room(blivedm.BLiveClient): author_type = 1 # 舰队 else: author_type = 0 + + need_translate = self._need_translate(danmaku.msg) + if need_translate: + translation = models.translate.get_translation_from_cache(danmaku.msg) + if translation is None: + # 没有缓存,需要后面异步翻译后通知 + translation = '' + else: + need_translate = False + else: + translation = '' + + id_ = uuid.uuid4().hex # 为了节省带宽用list而不是dict self.send_message(Command.ADD_TEXT, [ # 0: avatarUrl @@ -145,15 +171,24 @@ class Room(blivedm.BLiveClient): # 9: isMobileVerified 1 if danmaku.mobile_verify else 0, # 10: medalLevel - 0 if danmaku.room_id != self.room_id else danmaku.medal_level + 0 if danmaku.room_id != self.room_id else danmaku.medal_level, + # 11: id + id_, + # 12: translation + translation ]) + if need_translate: + await self._translate_and_response(danmaku.msg, id_) + async def _on_receive_gift(self, gift: blivedm.GiftMessage): avatar_url = models.avatar.process_avatar_url(gift.face) models.avatar.update_avatar_cache(gift.uid, avatar_url) if gift.coin_type != 'gold': # 丢人 return + id_ = uuid.uuid4().hex self.send_message(Command.ADD_GIFT, { + 'id': id_, 'avatarUrl': avatar_url, 'timestamp': gift.timestamp, 'authorName': gift.uname, @@ -164,7 +199,9 @@ class Room(blivedm.BLiveClient): asyncio.ensure_future(self.__on_buy_guard(message)) async def __on_buy_guard(self, message: blivedm.GuardBuyMessage): + id_ = uuid.uuid4().hex self.send_message(Command.ADD_MEMBER, { + 'id': id_, 'avatarUrl': await models.avatar.get_avatar_url(message.uid), 'timestamp': message.start_time, 'authorName': message.username @@ -173,20 +210,59 @@ class Room(blivedm.BLiveClient): async def _on_super_chat(self, message: blivedm.SuperChatMessage): avatar_url = models.avatar.process_avatar_url(message.face) models.avatar.update_avatar_cache(message.uid, avatar_url) + + need_translate = self._need_translate(message.message) + if need_translate: + translation = models.translate.get_translation_from_cache(message.message) + if translation is None: + # 没有缓存,需要后面异步翻译后通知 + translation = '' + else: + need_translate = False + else: + translation = '' + + id_ = str(message.id) self.send_message(Command.ADD_SUPER_CHAT, { + 'id': id_, 'avatarUrl': avatar_url, 'timestamp': message.start_time, 'authorName': message.uname, 'price': message.price, 'content': message.message, - 'id': message.id + 'translation': translation }) + if need_translate: + asyncio.ensure_future(self._translate_and_response(message.message, id_)) + async def _on_super_chat_delete(self, message: blivedm.SuperChatDeleteMessage): self.send_message(Command.ADD_SUPER_CHAT, { - 'ids': message.ids + 'ids': list(map(str, message.ids)) }) + def _need_translate(self, text): + return ( + config.get_config().enable_translate + and self.auto_translate_count > 0 + and models.translate.need_translate(text) + ) + + async def _translate_and_response(self, text, msg_id): + translation = await models.translate.translate(text) + if translation is None: + return + self.send_message_if( + lambda client: client.auto_translate, + Command.UPDATE_TRANSLATION, + [ + # 0: id + msg_id, + # 1: translation + translation + ] + ) + class RoomManager: def __init__(self): @@ -200,6 +276,8 @@ class RoomManager: room = self._rooms[room_id] room.clients.append(client) logger.info('%d clients in room %s', len(room.clients), room_id) + if client.auto_translate: + room.auto_translate_count += 1 if client.application.settings['debug']: await client.send_test_message() @@ -210,6 +288,8 @@ class RoomManager: room = self._rooms[room_id] room.clients.remove(client) logger.info('%d clients in room %s', len(room.clients), room_id) + if client.auto_translate: + room.auto_translate_count -= 1 if not room.clients: self._del_room(room_id) @@ -243,6 +323,7 @@ class ChatHandler(tornado.websocket.WebSocketHandler): super().__init__(*args, **kwargs) self._close_on_timeout_future = None self.room_id = None + self.auto_translate = False def open(self): logger.info('Websocket connected %s', self.request.remote_ip) @@ -268,6 +349,11 @@ class ChatHandler(tornado.websocket.WebSocketHandler): return self.room_id = int(body['data']['roomId']) logger.info('Client %s is joining room %d', self.request.remote_ip, self.room_id) + try: + cfg = body['data']['config'] + self.auto_translate = cfg['autoTranslate'] + except KeyError: + pass asyncio.ensure_future(room_manager.add_client(self.room_id, self)) self._close_on_timeout_future.cancel() @@ -320,32 +406,43 @@ class ChatHandler(tornado.websocket.WebSocketHandler): # 9: isMobileVerified 1, # 10: medalLevel - 0 + 0, + # 11: id + uuid.uuid4().hex, + # 12: translation + '' ] - member_data = base_data.copy() + member_data = { + **base_data, + 'id': uuid.uuid4().hex + } gift_data = { **base_data, + 'id': uuid.uuid4().hex, 'totalCoin': 450000 } sc_data = { **base_data, + 'id': str(random.randint(1, 65535)), 'price': 30, 'content': 'The quick brown fox jumps over the lazy dog', - 'id': random.randint(1, 65535) + 'translation': '' } self.send_message(Command.ADD_TEXT, text_data) text_data[2] = '主播' text_data[3] = 3 text_data[4] = "I can eat glass, it doesn't hurt me." + text_data[11] = uuid.uuid4().hex self.send_message(Command.ADD_TEXT, text_data) self.send_message(Command.ADD_MEMBER, member_data) self.send_message(Command.ADD_SUPER_CHAT, sc_data) + sc_data['id'] = str(random.randint(1, 65535)) sc_data['price'] = 100 sc_data['content'] = '敏捷的棕色狐狸跳过了懒狗' - sc_data['id'] = random.randint(1, 65535) self.send_message(Command.ADD_SUPER_CHAT, sc_data) # self.send_message(Command.DEL_SUPER_CHAT, {'ids': [sc_data['id']]}) self.send_message(Command.ADD_GIFT, gift_data) + gift_data['id'] = uuid.uuid4().hex gift_data['totalCoin'] = 1245000 self.send_message(Command.ADD_GIFT, gift_data) diff --git a/config.py b/config.py index 72098d0..47f348f 100644 --- a/config.py +++ b/config.py @@ -30,6 +30,7 @@ def get_config(): class AppConfig: def __init__(self): self.database_url = 'sqlite:///data/database.db' + self.enable_translate = True def load(self, path): config = configparser.ConfigParser() @@ -37,6 +38,7 @@ class AppConfig: try: app_section = config['app'] self.database_url = app_section['database_url'] + self.enable_translate = app_section.getboolean('enable_translate') except (KeyError, ValueError): logger.exception('Failed to load config:') return False diff --git a/data/config.ini b/data/config.ini index 41bd9ec..8e6edd6 100644 --- a/data/config.ini +++ b/data/config.ini @@ -1,8 +1,11 @@ [app] # See https://docs.sqlalchemy.org/en/13/core/engines.html#database-urls database_url = sqlite:///data/database.db +# Enable auto translate to Japanese +enable_translate = true # DON'T modify this section [DEFAULT] database_url = sqlite:///data/database.db +enable_translate = true diff --git a/frontend/src/api/config.js b/frontend/src/api/config.js index 192c6fb..b46fb7e 100644 --- a/frontend/src/api/config.js +++ b/frontend/src/api/config.js @@ -13,7 +13,9 @@ export const DEFAULT_CONFIG = { blockNotMobileVerified: true, blockKeywords: '', blockUsers: '', - blockMedalLevel: 0 + blockMedalLevel: 0, + + autoTranslate: false } export function setLocalConfig (config) { diff --git a/frontend/src/components/ChatRenderer/index.vue b/frontend/src/components/ChatRenderer/index.vue index 2ec84fe..a450851 100644 --- a/frontend/src/components/ChatRenderer/index.vue +++ b/frontend/src/components/ChatRenderer/index.vue @@ -13,7 +13,7 @@ @@ -118,6 +118,13 @@ export default { this.clearMessages() }, methods: { + getShowContent(message) { + if (message.translation) { + return `${message.content}(${message.translation})` + } + return message.content + }, + addMessage(message) { this.addMessages([message]) }, @@ -212,6 +219,29 @@ export default { this.scrollToBottom() } }, + updateMessage(id, newValuesObj) { + // 遍历滚动的消息 + this.forEachRecentMessage(999999999, message => { + if (message.id !== id) { + return true + } + for (let name in newValuesObj) { + message[name] = newValuesObj[name] + } + return false + }) + // 遍历固定的消息 + for (let message of this.paidMessages) { + if (message.id !== id) { + continue + } + for (let name in newValuesObj) { + message[name] = newValuesObj[name] + } + break + } + // TODO 解决自动滚动的问题 + }, enqueueMessages(messages) { if (this.lastEnqueueTime) { diff --git a/frontend/src/lang/en.js b/frontend/src/lang/en.js index 1c5c79f..ac7eecf 100644 --- a/frontend/src/lang/en.js +++ b/frontend/src/lang/en.js @@ -28,6 +28,9 @@ export default { blockUsers: 'Block users', blockMedalLevel: 'Block medal level lower than', + advanced: 'Advanced', + autoTranslate: 'Auto translate messages to Japanese', + roomUrl: 'Room URL', copy: 'Copy', enterRoom: 'Enter room', diff --git a/frontend/src/lang/ja.js b/frontend/src/lang/ja.js index d346ec6..dd498f8 100644 --- a/frontend/src/lang/ja.js +++ b/frontend/src/lang/ja.js @@ -28,6 +28,9 @@ export default { blockUsers: 'ブロックユーザー', blockMedalLevel: 'ブロック勲章等級がx未満', + advanced: 'アドバンス', + autoTranslate: '自動翻訳コメントから日本語へ', + roomUrl: 'ルームのURL', copy: 'コピー', enterRoom: 'ルームに入る', diff --git a/frontend/src/lang/zh.js b/frontend/src/lang/zh.js index 9a993d6..63cc51b 100644 --- a/frontend/src/lang/zh.js +++ b/frontend/src/lang/zh.js @@ -28,6 +28,9 @@ export default { blockUsers: '屏蔽用户', blockMedalLevel: '屏蔽当前直播间勋章等级低于', + advanced: '高级', + autoTranslate: '自动翻译弹幕到日语', + roomUrl: '房间URL', copy: '复制', enterRoom: '进入房间', diff --git a/frontend/src/utils.js b/frontend/src/utils.js index ac6d44d..d5a6932 100644 --- a/frontend/src/utils.js +++ b/frontend/src/utils.js @@ -8,7 +8,7 @@ export function mergeConfig (config, defaultConfig) { export function toBool (val) { if (typeof val === 'string') { - return val !== 'false' && val !== '' + return ['false', 'no', 'off', '0', ''].indexOf(val.toLowerCase()) === -1 } return !!val } diff --git a/frontend/src/views/Home.vue b/frontend/src/views/Home.vue index cf04cfb..617c057 100644 --- a/frontend/src/views/Home.vue +++ b/frontend/src/views/Home.vue @@ -50,6 +50,12 @@ + + + + + + diff --git a/frontend/src/views/Room.vue b/frontend/src/views/Room.vue index e5ae1b4..1b86093 100644 --- a/frontend/src/views/Room.vue +++ b/frontend/src/views/Room.vue @@ -15,6 +15,7 @@ const COMMAND_ADD_GIFT = 3 const COMMAND_ADD_MEMBER = 4 const COMMAND_ADD_SUPER_CHAT = 5 const COMMAND_DEL_SUPER_CHAT = 6 +const COMMAND_UPDATE_TRANSLATION = 7 export default { name: 'Room', @@ -28,9 +29,7 @@ export default { websocket: null, retryCount: 0, isDestroying: false, - heartbeatTimerId: null, - - nextId: 0, + heartbeatTimerId: null } }, computed: { @@ -42,8 +41,8 @@ export default { } }, created() { - this.wsConnect() this.updateConfig() + this.wsConnect() }, beforeDestroy() { this.isDestroying = true @@ -70,6 +69,7 @@ export default { cfg.blockNewbie = toBool(cfg.blockNewbie) cfg.blockNotMobileVerified = toBool(cfg.blockNotMobileVerified) cfg.blockMedalLevel = toInt(cfg.blockMedalLevel, config.DEFAULT_CONFIG.blockMedalLevel) + cfg.autoTranslate = toBool(cfg.autoTranslate) this.config = cfg }, @@ -94,7 +94,10 @@ export default { this.websocket.send(JSON.stringify({ cmd: COMMAND_JOIN_ROOM, data: { - roomId: parseInt(this.$route.params.roomId) + roomId: parseInt(this.$route.params.roomId), + config: { + autoTranslate: this.config.autoTranslate + } } })) }, @@ -125,13 +128,15 @@ export default { authorLevel: data[7], isNewbie: !!data[8], isMobileVerified: !!data[9], - medalLevel: data[10] + medalLevel: data[10], + id: data[11], + translation: data[12] } if (!this.config.showDanmaku || !this.filterTextMessage(data) || this.mergeSimilarText(data.content)) { break } message = { - id: `text_${this.nextId++}`, + id: data.id, type: constants.MESSAGE_TYPE_TEXT, avatarUrl: data.avatarUrl, time: new Date(data.timestamp * 1000), @@ -139,7 +144,8 @@ export default { authorType: data.authorType, content: data.content, privilegeType: data.privilegeType, - repeated: 1 + repeated: 1, + translation: data.translation } break case COMMAND_ADD_GIFT: { @@ -154,7 +160,7 @@ export default { break } message = { - id: `gift_${this.nextId++}`, + id: data.id, type: constants.MESSAGE_TYPE_SUPER_CHAT, avatarUrl: data.avatarUrl, authorName: data.authorName, @@ -169,7 +175,7 @@ export default { break } message = { - id: `member_${this.nextId++}`, + id: data.id, type: constants.MESSAGE_TYPE_MEMBER, avatarUrl: data.avatarUrl, time: new Date(data.timestamp * 1000), @@ -186,7 +192,7 @@ export default { break } message = { - id: `sc_${data.id}`, + id: data.id, type: constants.MESSAGE_TYPE_SUPER_CHAT, avatarUrl: data.avatarUrl, authorName: data.authorName, @@ -197,10 +203,19 @@ export default { break case COMMAND_DEL_SUPER_CHAT: for (let id of data.ids) { - id = `sc_${id}` this.$refs.renderer.delMessage(id) } break + case COMMAND_UPDATE_TRANSLATION: + if (!this.config.autoTranslate) { + break + } + data = { + id: data[0], + translation: data[1] + } + this.$refs.renderer.updateMessage(data.id, {translation: data.translation}) + break } if (message) { this.$refs.renderer.addMessage(message) diff --git a/frontend/src/views/StyleGenerator/index.vue b/frontend/src/views/StyleGenerator/index.vue index 950e17a..ff3ae1c 100644 --- a/frontend/src/views/StyleGenerator/index.vue +++ b/frontend/src/views/StyleGenerator/index.vue @@ -216,7 +216,8 @@ let textMessageTemplate = { authorType: constants.AUTHRO_TYPE_NORMAL, content: '', privilegeType: 0, - repeated: 1 + repeated: 1, + translation: '' } let legacyPaidMessageTemplate = { id: 0, @@ -236,20 +237,21 @@ let paidMessageTemplate = { authorName: '', price: 0, time: time, - content: '' + content: '', + translation: '' } let nextId = 0 const EXAMPLE_MESSAGES = [ { ...textMessageTemplate, - id: nextId++, + id: (nextId++).toString(), authorName: 'mob路人', content: '8888888888', repeated: 12 }, { ...textMessageTemplate, - id: nextId++, + id: (nextId++).toString(), authorName: 'member舰长', authorType: constants.AUTHRO_TYPE_MEMBER, content: '草', @@ -258,34 +260,34 @@ const EXAMPLE_MESSAGES = [ }, { ...textMessageTemplate, - id: nextId++, + id: (nextId++).toString(), authorName: 'admin房管', authorType: constants.AUTHRO_TYPE_ADMIN, content: 'kksk' }, { ...legacyPaidMessageTemplate, - id: nextId++, + id: (nextId++).toString(), authorName: '少年Pi', content: 'Welcome 少年Pi!' }, { ...paidMessageTemplate, - id: nextId++, + id: (nextId++).toString(), authorName: '无火的残渣', price: 66600, content: 'Sent 小电视飞船x100' }, { ...textMessageTemplate, - id: nextId++, + id: (nextId++).toString(), authorName: 'streamer主播', authorType: constants.AUTHRO_TYPE_OWNER, content: '老板大气,老板身体健康' }, { ...paidMessageTemplate, - id: nextId++, + id: (nextId++).toString(), authorName: '夏色祭保護協会会長', price: 30, content: '言いたいことがあるんだよ!'