├── LICENSE ├── README.md ├── main.lua └── screenshot.webp /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021-Present CrendKing 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [mpv Twitch Chat](https://github.com/CrendKing/mpv-twitch-chat) 2 | 3 | Show Twitch chat messages as subtitles when watching Twitch VOD with mpv. 4 | 5 | mpv internally uses youtube-dl to handle Twitch VOD URL. In addition to the regular video track, it also adds a "rechat" subtitle track. This track points to the Twitch API `videos//comments`, which contains the full transcript of a VOD's chat messages in JSON. Unfortunately, mpv can't directly consume the JSON as subtitle. This script converts it into a SubRip subtitle track so that mpv can directly display the chat messages. 6 | 7 | Note that since subtitle is text-based only, all Twitch emotes are shown as text. 8 | 9 | The API this script uses to get the chat data is undocumented and not intended for public use, [according to Twitch](https://discuss.dev.twitch.tv/t/getting-chat-messages-on-new-api/26176). It could change or be broken at any moment, and there is no guarantee from the author. Use at your own risk. 10 | 11 | ## Features 12 | 13 | * Configurable to show commenter's name. 14 | * Configurable to show colored messages. 15 | * Configurable message duration. 16 | * Friendly to mpv's built-in subtitle options. 17 | 18 | ## Requirement 19 | 20 | * [curl](https://curl.se/), which should be preinstalled in most operating systems 21 | 22 | ## Install 23 | 24 | Best way to install is `git clone` this repo in mpv's "scripts" directory. This approach allows easy update by simply `git pull`. 25 | 26 | Alternatively, one can [download the repo as zip](https://github.com/CrendKing/mpv-twitch-chat/archive/refs/heads/master.zip) and extract to mpv's "scripts" directory. Updates must be made manually. 27 | 28 | Script option file should be placed in mpv's `script-opts` directory as usual. Options are explains in the script file. 29 | 30 | User should specify a working Twitch API client ID in the option file. 31 | 32 | ## Usage 33 | 34 | To activate the script, play a Twitch VOD and switch on the "rechat" subtitle track. The script will replace it with its own subtitle track. 35 | 36 | You can use mpv's auto profiles to conditionally apply special subtitle options when Twitch VOD is on. For example, 37 | ``` 38 | [twitch] 39 | profile-cond=get("path", ""):find("^https://www.twitch.tv/") ~= nil 40 | profile-restore=copy-equal 41 | sub-font-size=30 42 | sub-align-x=right 43 | sub-align-y=top 44 | ``` 45 | makes the Twitch chat subtitles smaller than default, and moved to the top right corner. 46 | 47 | ## Screenshot 48 | 49 | ![Screenshot](screenshot.webp) 50 | -------------------------------------------------------------------------------- /main.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | 3 | License: https://github.com/CrendKing/mpv-twitch-chat/blob/master/LICENSE 4 | 5 | Options: 6 | 7 | twitch_client_id: Client ID to be used to request the comments from Twitch API. 8 | 9 | show_name: Whether to show the commenter's name. 10 | 11 | color: If show_name is enabled, color the commenter's name with its user color. Otherwise, color the whole message. 12 | 13 | duration_multiplier: Each chat message's duration is calculated based on the density of the messages at the time after 14 | applying this multiplier. Basically, if you want more messages simultaneously on screen, increase this number. 15 | 16 | max_duration: Maximum duration in seconds of each chat message after applying the previous multiplier. This exists to prevent 17 | messages to stay forever in "cold" segments. 18 | 19 | max_message_length: Break long messages into lines with at most this much length. Specify 0 to disable line breaking. 20 | 21 | fetch_aot: The chat data is downloaded in segments. This script uses timer to fetch new segments this many seconds before the 22 | current segment is exhausted. Increase this number to avoid interruption if you have slower network to Twitch. 23 | 24 | --]] 25 | 26 | local TWITCH_GRAPHQL_URL = 'https://gql.twitch.tv/gql' 27 | 28 | local o = { 29 | twitch_client_id = '', -- replace this with a working Twitch Client ID 30 | show_name = false, 31 | color = true, 32 | duration_multiplier = 10, 33 | max_duration = 10, 34 | max_message_length = 40, 35 | fetch_aot = 1, 36 | } 37 | 38 | require('mp.options').read_options(o) 39 | 40 | local utils = require 'mp.utils' 41 | 42 | -- sid to be operated on 43 | local chat_sid 44 | -- Twitch video id 45 | local twitch_video_id 46 | -- next segment ID to fetch from Twitch 47 | local twitch_cursor 48 | -- two fifo segments for cycling the subtitle text 49 | local curr_segment 50 | local next_segment 51 | -- SubRip sequence counter 52 | local seq_counter 53 | -- timer to fetch new segments of the chat data 54 | local timer 55 | -- delimiters to specify where to allow lines to add graceful line breaks at 56 | local delimiter_pattern = ' %.,%-!%?' 57 | 58 | local function split_string(input) 59 | local splits = {} 60 | 61 | for segment in string.gmatch(input, string.format('[^%s]+[%s]*', delimiter_pattern, delimiter_pattern)) do 62 | table.insert(splits, segment) 63 | end 64 | 65 | return splits 66 | end 67 | 68 | local function break_message_body(message_body) 69 | if o.max_message_length <= 0 then 70 | return message_body 71 | end 72 | 73 | local length_sofar = 0 74 | local ret = '' 75 | 76 | for _, v in ipairs(split_string(message_body)) do 77 | length_sofar = length_sofar + #v 78 | 79 | if length_sofar > o.max_message_length then 80 | -- assume #v is always < o.max_message_length for simplicity 81 | ret = string.format('%s\n%s', ret, v) 82 | length_sofar = #v 83 | else 84 | ret = string.format('%s%s', ret, v) 85 | end 86 | end 87 | 88 | return ret 89 | end 90 | 91 | local function load_twitch_chat(is_new_session) 92 | if not chat_sid or not twitch_video_id then 93 | return 94 | end 95 | 96 | local request_body = { 97 | ['operationName'] = 'VideoCommentsByOffsetOrCursor', 98 | ['variables'] = { 99 | ['videoID'] = twitch_video_id, 100 | }, 101 | ['extensions'] = { 102 | ['persistedQuery'] = { 103 | ['version'] = 1, 104 | ['sha256Hash'] = 'b70a3591ff0f4e0313d126c6a1502d79a1c02baebb288227c582044aa76adf6a', 105 | }, 106 | }, 107 | } 108 | 109 | if is_new_session then 110 | local time_pos = mp.get_property_native('time-pos') 111 | if not time_pos then 112 | return 113 | end 114 | 115 | request_body.variables.contentOffsetSeconds = math.max(math.floor(time_pos), 0) 116 | next_segment = '' 117 | seq_counter = 0 118 | else 119 | request_body.variables.cursor = twitch_cursor 120 | end 121 | 122 | local sp_ret = mp.command_native({ 123 | name = 'subprocess', 124 | capture_stdout = true, 125 | args = { 'curl', '--request', 'POST', '--header', string.format('Client-ID: %s', o.twitch_client_id), '--data', utils.format_json(request_body), '--silent', TWITCH_GRAPHQL_URL }, 126 | }) 127 | 128 | if sp_ret.status ~= 0 then 129 | mp.msg.error(string.format('Error curl exit code: %d', sp_ret.status)) 130 | return 131 | end 132 | 133 | local resp_json = utils.parse_json(sp_ret.stdout) 134 | if resp_json.errors then 135 | for _, error in ipairs(resp_json.errors) do 136 | mp.msg.error(string.format('Error from Twitch: %s', error.message)) 137 | end 138 | return 139 | end 140 | 141 | local comments = resp_json.data.video.comments.edges 142 | if not comments then 143 | mp.msg.error(string.format('Failed to download comments JSON: %s', sp_ret.stdout)) 144 | return 145 | end 146 | 147 | twitch_cursor = comments[1].cursor 148 | curr_segment = next_segment 149 | next_segment = '' 150 | 151 | local last_msg_offset = comments[#comments].node.contentOffsetSeconds 152 | local segment_duration = last_msg_offset - comments[1].node.contentOffsetSeconds 153 | local per_msg_duration = math.min(segment_duration * o.duration_multiplier / #comments, o.max_duration) 154 | 155 | for _, curr_comment in ipairs(comments) do 156 | local curr_comment_node = curr_comment.node 157 | 158 | local msg_time_from = curr_comment_node.contentOffsetSeconds 159 | local msg_time_from_ms = math.floor(msg_time_from * 1000) % 1000 160 | local msg_time_from_sec = math.floor(msg_time_from) % 60 161 | local msg_time_from_min = math.floor(msg_time_from / 60) % 60 162 | local msg_time_from_hour = math.floor(msg_time_from / 3600) 163 | 164 | local msg_time_to = msg_time_from + per_msg_duration 165 | local msg_time_to_ms = math.floor(msg_time_to * 1000) % 1000 166 | local msg_time_to_sec = math.floor(msg_time_to) % 60 167 | local msg_time_to_min = math.floor(msg_time_to / 60) % 60 168 | local msg_time_to_hour = math.floor(msg_time_to / 3600) 169 | 170 | local msg_text = '' 171 | for _, frag in ipairs(curr_comment_node.message.fragments) do 172 | msg_text = string.format('%s%s%s%s', msg_text, frag.emote and '' or '', frag.text, frag.emote and '' or '') 173 | end 174 | 175 | local msg_part_1, msg_part_2, msg_separator 176 | if o.show_name and curr_comment_node.commenter then 177 | msg_part_1 = curr_comment_node.commenter.displayName 178 | msg_part_2 = break_message_body(msg_text) 179 | msg_separator = ': ' 180 | else 181 | msg_part_1 = break_message_body(msg_text) 182 | msg_part_2 = '' 183 | msg_separator = '' 184 | end 185 | 186 | if o.color then 187 | local msg_color 188 | 189 | if curr_comment_node.message.userColor then 190 | msg_color = curr_comment_node.message.userColor 191 | elseif curr_comment_node.commenter then 192 | msg_color = string.format('#%06x', curr_comment_node.commenter.id % 16777216) 193 | end 194 | 195 | if msg_color then 196 | msg_part_1 = string.format('%s', msg_color, msg_part_1) 197 | end 198 | end 199 | 200 | local msg_line = string.format('%s%s%s', msg_part_1, msg_separator, msg_part_2) 201 | 202 | local subtitle = string.format([[%i 203 | %i:%i:%i,%i --> %i:%i:%i,%i 204 | %s 205 | 206 | ]], 207 | seq_counter, 208 | msg_time_from_hour, msg_time_from_min, msg_time_from_sec, msg_time_from_ms, 209 | msg_time_to_hour, msg_time_to_min, msg_time_to_sec, msg_time_to_ms, 210 | msg_line) 211 | next_segment = string.format('%s%s', next_segment, subtitle) 212 | seq_counter = seq_counter + 1 213 | end 214 | 215 | mp.command_native({ 216 | name = 'sub-remove', 217 | id = chat_sid, 218 | }) 219 | mp.command_native({ 220 | name = 'sub-add', 221 | url = string.format('memory://%s%s', curr_segment, next_segment), 222 | title = 'Twitch Chat', 223 | }) 224 | chat_sid = mp.get_property_native('sid') 225 | 226 | return last_msg_offset 227 | end 228 | 229 | local function init() 230 | twitch_video_id = nil 231 | end 232 | 233 | local function timer_callback(is_new_session) 234 | local last_msg_offset = load_twitch_chat(is_new_session) 235 | if last_msg_offset then 236 | local fetch_delay = last_msg_offset - mp.get_property_native('time-pos') - o.fetch_aot 237 | timer = mp.add_timeout(fetch_delay, function () 238 | timer_callback(false) 239 | end) 240 | end 241 | end 242 | 243 | local function handle_track_change(name, sid) 244 | if not sid and timer then 245 | timer:kill() 246 | timer = nil 247 | elseif sid and not timer then 248 | if not twitch_video_id then 249 | local sub_filename = mp.get_property_native('current-tracks/sub/external-filename') 250 | if sub_filename then 251 | local twitch_client_id_from_track 252 | twitch_video_id, twitch_client_id_from_track = sub_filename:match('https://api%.twitch%.tv/v5/videos/(%d+)/comments%?client_id=(%w+)') 253 | 254 | if twitch_client_id_from_track and o.twitch_client_id == '' then 255 | o.twitch_client_id = twitch_client_id_from_track 256 | end 257 | end 258 | end 259 | 260 | if twitch_video_id then 261 | chat_sid = sid 262 | mp.commandv('sub-remove', chat_sid) 263 | timer_callback(true) 264 | end 265 | end 266 | end 267 | 268 | local function handle_seek() 269 | if mp.get_property_native('sid') then 270 | load_twitch_chat(true) 271 | end 272 | end 273 | 274 | local function handle_pause(name, is_paused) 275 | if timer then 276 | if is_paused then 277 | timer:stop() 278 | else 279 | timer:resume() 280 | end 281 | end 282 | end 283 | 284 | mp.register_event('start-file', init) 285 | mp.observe_property('current-tracks/sub/id', 'native', handle_track_change) 286 | mp.register_event('seek', handle_seek) 287 | mp.observe_property('pause', 'native', handle_pause) 288 | -------------------------------------------------------------------------------- /screenshot.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CrendKing/mpv-twitch-chat/4d88ac12c843da0e916b0ed1df4d087a3418501b/screenshot.webp --------------------------------------------------------------------------------