├── README.md ├── ircdump.py └── main.lua /README.md: -------------------------------------------------------------------------------- 1 | Show Twitch chat messages as subtitles when watching Twitch Live with mpv. 2 | 3 | Based on [mpv Twitch Chat](https://github.com/CrendKing/mpv-twitch-chat/) for VODs, but since `mpv-twitch-chat` uses Twitch API to retrieve comments history, it doesn't support live chat comments. This script uses a python subprocess to keep a background irc connection to a channel and dump last 10 messages to file in SubRip format. 4 | 5 | ## Issues 6 | 7 | * Subtitle track is deleted and re-added on every refresh, so it blinks. 8 | 9 | ## Requirement 10 | 11 | python3 12 | 13 | ## Install 14 | 15 | `git clone` or download and unpack to mpv's `scripts` directory. 16 | 17 | `pip install certifi` – added `certifi` dependency to fix "certificate verify failed: unable to get local issuer certificate" error popped out on some win10 system; 18 | 19 | 20 | ## TODO 21 | 22 | Remove python dependency: re-write in Lua using [lua-irc-engine](https://github.com/mirrexagon/lua-irc-engine) and [luasocket](https://github.com/diegonehab/luasocket). 23 | 24 | ## Usage 25 | 26 | To activate the script, play a Twitch Live and switch on the "Twitch Chat" subtitle track. The script will replace it with its own subtitle track. 27 | 28 | You can use mpv's auto profiles to conditionally apply special subtitle options when Twitch Live is on. For example, 29 | ``` 30 | [twitch] 31 | profile-cond=get("path", ""):find("^https://w?w?w?%.?twitch%.tv/") ~= nil 32 | profile-restore=copy-equal 33 | sub-font-size=25 34 | sub-align-x=right 35 | sub-align-y=top 36 | ``` 37 | makes the Twitch chat subtitles smaller than default, and moved to the top right corner. -------------------------------------------------------------------------------- /ircdump.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import pathlib 3 | import socket 4 | import ssl 5 | import certifi 6 | import random 7 | import textwrap 8 | 9 | 10 | def main(): 11 | channel = sys.argv[1] 12 | dumpfile = pathlib.Path(__file__).parent.joinpath(f'{channel}.txt') 13 | connect_and_dump_loop(dumpfile, channel) 14 | 15 | 16 | def ssl_socket(server, port): 17 | context = ssl.create_default_context(cafile=certifi.where(), purpose=ssl.Purpose.SERVER_AUTH) 18 | sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 19 | ssock = context.wrap_socket(sock, server_hostname='irc.chat.twitch.tv') 20 | ssock.connect((server, port)) 21 | return ssock 22 | 23 | 24 | def connect_and_dump_loop(dumpfile, channel, server='irc.chat.twitch.tv', port=6697): 25 | name_postfix = ''.join([str(i) for i in random.sample(range(0, 9), 3)]) 26 | USERNAME = f'justinfan{name_postfix}' 27 | PASSWORD = 'kappa' 28 | comments = [] 29 | 30 | if not channel.startswith('#'): 31 | channel = '#' + channel 32 | 33 | conn = ssl_socket(server, port) 34 | send_cmd(conn, 'NICK', USERNAME) 35 | send_cmd(conn, 'PASS', PASSWORD) 36 | send_cmd(conn, 'JOIN', channel) 37 | 38 | while True: 39 | resp = parsemsg( conn.recv(1024).decode('utf-8') ) 40 | if not resp: 41 | continue 42 | (prefix, command, args) = resp 43 | 44 | if command == 'PING': 45 | send_cmd(conn, 'PONG', ':' + ''.join(args)) 46 | elif command == 'PRIVMSG': 47 | user = prefix.split('!')[0] 48 | msg_color = '{:06x}'.format( hash(user) % 16777216 ) 49 | msg_text = '\n'.join( textwrap.wrap(args[1].strip(), width=40) ) 50 | msg_line = f'{user}: {msg_text}' 51 | comments = comments[-9:] + [msg_line] 52 | comments_str = '\n'.join(comments) 53 | subtitle = f'1\n0:0:0,0 --> 999:0:0,0\n{comments_str}\n' 54 | with open(dumpfile, 'w', encoding='utf-8') as f: 55 | f.write(f'{subtitle}\n') 56 | 57 | 58 | def send_cmd(conn, cmd, message): 59 | command = '{} {}\r\n'.format(cmd, message).encode('utf-8') 60 | print(f'>> {command}') 61 | conn.send(command) 62 | 63 | 64 | # https://stackoverflow.com/a/930706 65 | def parsemsg(s): 66 | prefix = '' 67 | trailing = [] 68 | if not s: 69 | return None 70 | if s[0] == ':': 71 | prefix, s = s[1:].split(' ', 1) 72 | if s.find(' :') != -1: 73 | s, trailing = s.split(' :', 1) 74 | args = s.split() 75 | args.append(trailing) 76 | else: 77 | args = s.split() 78 | command = args.pop(0) 79 | return prefix, command, args 80 | 81 | 82 | if __name__ == '__main__': 83 | main() 84 | -------------------------------------------------------------------------------- /main.lua: -------------------------------------------------------------------------------- 1 | local o = { 2 | fetch_aot = 5, 3 | } 4 | local utils = require "mp.utils" 5 | local options = require 'mp.options' 6 | local chat_sid 7 | local is_running 8 | local channelname 9 | local timer 10 | local ON_WINDOWS = package.config:sub(1,1) ~= "/" 11 | local python_path = ON_WINDOWS and "python" or "python3" 12 | local ircdump = utils.join_path(mp.get_script_directory(), "ircdump.py") 13 | options.read_options(o) 14 | 15 | if not mp.get_script_directory() then 16 | mp.msg.error("This script requires to be placed in a script directory") 17 | return 18 | end 19 | 20 | local function timer_callback() 21 | mp.command_native({"sub-remove", chat_sid}) 22 | mp.command_native({ 23 | name = "sub-add", 24 | url = utils.join_path(mp.get_script_directory(), channelname .. ".txt"), 25 | title = "Twitch Chat" 26 | }) 27 | chat_sid = mp.get_property_native("sid") 28 | 29 | local fetch_delay = o.fetch_aot 30 | timer = mp.add_timeout(fetch_delay, function() 31 | timer_callback() 32 | end) 33 | end 34 | 35 | local function handle_track_change(name, sid) 36 | if timer and sid then 37 | timer:resume() 38 | chat_sid = sid 39 | elseif timer and not sid then 40 | timer:stop() 41 | end 42 | end 43 | 44 | local function init() 45 | channelname = string.match(mp.get_property("path"), "^https://w?w?w?%.?twitch%.tv/([^/]-)$") 46 | if not channelname then 47 | return 48 | end 49 | local args = { 50 | python_path, 51 | ircdump, 52 | channelname, 53 | mp.get_script_directory(), 54 | } 55 | mp.command_native_async({ 56 | name = "subprocess", 57 | capture_stdout = false, 58 | playback_only = false, 59 | args = args, 60 | }) 61 | -- enable subtitles button 62 | mp.command_native({ 63 | name = "sub-add", 64 | url = "memory://" .. "1\n0:0:0,0 --> 999:0:0,0\nloading...", 65 | title = "Twitch Chat", 66 | }) 67 | chat_sid = mp.get_property_native("sid") 68 | timer_callback() 69 | end 70 | 71 | mp.register_event("start-file", init) 72 | mp.observe_property("current-tracks/sub/id", "native", handle_track_change) --------------------------------------------------------------------------------