--[[ Copyright (C) 2020 Ren Tatsumoto This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . Requirements: * mpv >= 0.32.0 * AnkiConnect * curl * xclip (when running X11) * wl-copy (when running Wayland) Usage: 1. Change `config` according to your needs * Config path: ~/.config/mpv/script-opts/subs2srs.conf * Config file isn't created automatically. 2. Open a video 3. Use key bindings to manipulate the script * Open mpvacious menu - `a` * Create a note from the current subtitle line - `Ctrl + e` For complete usage guide, see ]] local config = { -- Common autoclip = false, -- enable copying subs to the clipboard when mpv starts nuke_spaces = false, -- remove all spaces from exported anki cards clipboard_trim_enabled = true, -- remove unnecessary characters from strings before copying to the clipboard use_ffmpeg = false, -- if set to true, use ffmpeg to create audio clips and snapshots. by default use mpv. snapshot_format = "webp", -- webp or jpg snapshot_quality = 15, -- from 0=lowest to 100=highest snapshot_width = -2, -- a positive integer or -2 for auto snapshot_height = 200, -- same audio_format = "opus", -- opus or mp3 audio_bitrate = "18k", -- from 16k to 32k audio_padding = 0.12, -- Set a pad to the dialog timings. 0.5 = audio is padded by .5 seconds. 0 = disable. tie_volumes = false, -- if set to true, the volume of the outputted audio file depends on the volume of the player at the time of export menu_font_size = 25, -- Custom encoding args ffmpeg_audio_args = '-af silenceremove=1:0:-50dB', mpv_audio_args = '--af-append=silenceremove=1:0:-50dB', -- Anki create_deck = false, -- automatically create a deck for new cards allow_duplicates = false, -- allow making notes with the same sentence field deck_name = "Learning", -- name of the deck for new cards model_name = "Japanese sentences", -- Tools -> Manage note types sentence_field = "SentKanji", audio_field = "SentAudio", image_field = "Image", append_media = true, -- True to append video media after existing data, false to insert media before -- Note tagging -- The tag(s) added to new notes. Spaces separate multiple tags. -- Change to "" to disable tagging completely. -- The following substitutions are supported: -- %n - the name of the video -- %t - timestamp -- %d - episode number (if none found, returns nothing) -- %e - SUBS2SRS_TAGS environment variable note_tag = "subs2srs %n", tag_nuke_brackets = true, -- delete all text inside brackets before substituting filename into tag tag_nuke_parentheses = false, -- delete all text inside parentheses before substituting filename into tag tag_del_episode_num = true, -- delete the episode number if found tag_del_after_episode_num = true, -- delete everything after the found episode number (does nothing if tag_del_episode_num is disabled) tag_filename_lowercase = false, -- convert filename to lowercase for tagging. -- Misc info miscinfo_enable = true, miscinfo_field = "Notes", -- misc notes and source information field miscinfo_format = "%n EP%d (%t)", -- format string to use for the miscinfo_field, accepts note_tag-style format strings -- Forvo support use_forvo = "yes", -- 'yes', 'no', 'always' vocab_field = "VocabKanji", -- target word field vocab_audio_field = "VocabAudio", -- target word audio } -- Defines config profiles -- Each name references a file in ~/.config/mpv/script-opts/*.conf -- Profiles themselves are defined in ~/.config/mpv/script-opts/subs2srs_profiles.conf local profiles = { profiles = "subs2srs,subs2srs_english", active = "subs2srs", } package.path = package.path .. ";/Users/flurry/.config/mpv/scripts/?.lua" local mp = require('mp') local utils = require('mp.utils') local msg = require('mp.msg') local config_manager = require('config') local encoder = require('encoder') -- namespaces local subs local clip_autocopy local menu local platform local append_forvo_pronunciation -- classes local Subtitle ------------------------------------------------------------ -- utility functions local unpack = unpack and unpack or table.unpack ---Returns true if table contains element. Returns false otherwise. ---@param table table ---@param element any ---@return boolean function table.contains(table, element) for _, value in pairs(table) do if value == element then return true end end return false end ---Returns the largest numeric index. ---@param table table ---@return number function table.max_num(table) local max = table[1] for _, value in ipairs(table) do if value > max then max = value end end return max end ---Returns a value for the given key. If key is not available then returns default value 'nil'. ---@param table table ---@param key string ---@param default any ---@return any function table.get(table, key, default) if table[key] == nil then return default or 'nil' else return table[key] end end local function is_empty(var) return var == nil or var == '' or (type(var) == 'table' and next(var) == nil) end local function is_running_windows() return mp.get_property('options/vo-mmcss-profile') ~= nil end local function is_running_macOS() return mp.get_property('options/cocoa-force-dedicated-gpu') ~= nil end local function is_running_wayland() return os.getenv('WAYLAND_DISPLAY') ~= nil end local function contains_non_latin_letters(str) return str:match("[^%c%p%s%w]") end local function capitalize_first_letter(string) return string:gsub("^%l", string.upper) end local function notify(message, level, duration) level = level or 'info' duration = duration or 1 msg[level](message) mp.osd_message(message, duration) end local escape_special_characters do local entities = { ['&'] = '&', ['"'] = '"', ["'"] = ''', ['<'] = '<', ['>'] = '>', } escape_special_characters = function(s) return s:gsub('[&"\'<>]', entities) end end local function remove_extension(filename) return filename:gsub('%.%w+$', '') end local function remove_special_characters(str) return str:gsub('[%c%p%s]', ''):gsub(' ', '') end local function remove_text_in_brackets(str) return str:gsub('%b[]', ''):gsub('【.-】', '') end local function remove_filename_text_in_parentheses(str) return str:gsub('%b()', ''):gsub('(.-)', '') end local function remove_common_resolutions(str) -- Also removes empty leftover parentheses and brackets. return str:gsub("2160p", ""):gsub("1080p", ""):gsub("720p", ""):gsub("576p", ""):gsub("480p", ""):gsub("%(%)", ""):gsub("%[%]", "") end local function remove_text_in_parentheses(str) -- Remove text like (泣き声) or (ドアの開く音) -- No deletion is performed if there's no text after the parentheses. -- Note: the modifier `-´ matches zero or more occurrences. -- However, instead of matching the longest sequence, it matches the shortest one. return str:gsub('(%b())(.)', '%2'):gsub('((.-))(.)', '%2') end local function remove_newlines(str) return str:gsub('[\n\r]+', ' ') end local function remove_leading_trailing_spaces(str) return str:gsub('^%s*(.-)%s*$', '%1') end local function remove_leading_trailing_dashes(str) return str:gsub('^[%-_]*(.-)[%-_]*$', '%1') end local function remove_all_spaces(str) return str:gsub('%s*', '') end local function remove_spaces(str) if config.nuke_spaces == true and contains_non_latin_letters(str) then return remove_all_spaces(str) else return remove_leading_trailing_spaces(str) end end local function trim(str) str = remove_spaces(str) str = remove_text_in_parentheses(str) str = remove_newlines(str) return str end local function copy_to_clipboard(_, text) if not is_empty(text) then text = config.clipboard_trim_enabled and trim(text) or remove_newlines(text) platform.copy_to_clipboard(text) end end local function copy_sub_to_clipboard() copy_to_clipboard("copy-on-demand", mp.get_property("sub-text")) end local function human_readable_time(seconds) if type(seconds) ~= 'number' or seconds < 0 then return 'empty' end local parts = { h = math.floor(seconds / 3600), m = math.floor(seconds / 60) % 60, s = math.floor(seconds % 60), ms = math.floor((seconds * 1000) % 1000), } local ret = string.format("%02dm%02ds%03dms", parts.m, parts.s, parts.ms) if parts.h > 0 then ret = string.format('%dh%s', parts.h, ret) end return ret end local function subprocess(args, completion_fn) -- if `completion_fn` is passed, the command is ran asynchronously, -- and upon completion, `completion_fn` is called to process the results. local command_native = type(completion_fn) == 'function' and mp.command_native_async or mp.command_native local command_table = { name = "subprocess", playback_only = false, capture_stdout = true, args = args } return command_native(command_table, completion_fn) end local codec_support = (function() return { snapshot = { libwebp = false, mjpeg = false, }, audio = { libmp3lame = false, libopus = false, }, } end)() local function warn_formats(osd) if config.use_ffmpeg then return end for type, codecs in pairs(codec_support) do for codec, supported in pairs(codecs) do if not supported and config[type .. '_codec'] == codec then osd:red('warning: '):newline() osd:tab():text(string.format("your version of mpv does not support %s.", codec)):newline() osd:tab():text(string.format("mpvacious won't be able to create %s files.", type)):newline() end end end end local function ensure_deck() if config.create_deck == true then ankiconnect.create_deck(config.deck_name) end end local function load_next_profile() config_manager.next_profile() ensure_deck() notify("Loaded profile " .. profiles.active) end local function get_episode_number(filename) -- Reverses the filename to start the search from the end as the media title might contain similar numbers. local filename_reversed = filename:reverse() local ep_num_patterns = { "%s?(%d?%d?%d)[pP]?[eE]", -- Starting with E or EP (case-insensitive). "Example Series S01E01" "%)(%d?%d?%d)%(", -- Surrounded by parentheses. "Example Series (12)" "%](%d?%d?%d)%[", -- Surrounded by brackets. "Example Series [01]" "%s(%d?%d?%d)%s", -- Surrounded by whitespace. "Example Series 124 [1080p 10-bit]" "_(%d?%d?%d)_", -- Surrounded by underscores. "Example_Series_04_1080p" "^(%d?%d?%d)[%s_]", -- Ending to the episode number. "Example Series 124" "(%d?%d?%d)%-edosipE", -- Prepended by "Episode-". "Example Episode-165" } local s, e, episode_num for _, pattern in pairs(ep_num_patterns) do s, e, episode_num = string.find(filename_reversed, pattern) if not is_empty(episode_num) then return #filename - e, #filename - s, episode_num:reverse() end end end local function tag_format(filename) filename = remove_extension(filename) filename = remove_common_resolutions(filename) local s, e, episode_num = get_episode_number(filename) if config.tag_del_episode_num == true and not is_empty(s) then if config.tag_del_after_episode_num == true then -- Removing everything (e.g. episode name) after the episode number including itself. filename = filename:sub(1, s) else -- Removing the first found instance of the episode number. filename = filename:sub(1, s) .. filename:sub(e + 1, -1) end end if config.tag_nuke_brackets == true then filename = remove_text_in_brackets(filename) end if config.tag_nuke_parentheses == true then filename = remove_filename_text_in_parentheses(filename) end if config.tag_filename_lowercase == true then filename = filename:lower() end filename = remove_leading_trailing_spaces(filename) filename = filename:gsub(" ", "_") filename = filename:gsub("_%-_", "_") -- Replaces garbage _-_ substrings with a underscore filename = remove_leading_trailing_dashes(filename) return filename, episode_num or '' end local function substitute_fmt(tag) local filename, episode = tag_format(mp.get_property("filename")) local function substitute_filename(_tag) return _tag:gsub("%%n", filename) end local function substitute_episode_number(_tag) return _tag:gsub("%%d", episode) end local function substitute_time_pos(_tag) local time_pos = human_readable_time(mp.get_property_number('time-pos')) return _tag:gsub("%%t", time_pos) end local function substitute_envvar(_tag) local env_tags = os.getenv('SUBS2SRS_TAGS') or '' return _tag:gsub("%%e", env_tags) end tag = substitute_filename(tag) tag = substitute_episode_number(tag) tag = substitute_time_pos(tag) tag = substitute_envvar(tag) tag = remove_leading_trailing_spaces(tag) return tag end local function construct_note_fields(sub_text, snapshot_filename, audio_filename) local ret = { [config.sentence_field] = sub_text, [config.image_field] = string.format('snapshot', snapshot_filename), [config.audio_field] = string.format('[sound:%s]', audio_filename), } if config.miscinfo_enable == true then ret[config.miscinfo_field] = substitute_fmt(config.miscinfo_format) end return ret end local function minutes_ago(m) return (os.time() - 60 * m) * 1000 end local function join_media_fields(new_data, stored_data) for _, field in pairs { config.audio_field, config.image_field, config.miscinfo_field } do new_data[field] = table.get(stored_data, field, "") .. table.get(new_data, field, "") end return new_data end local function update_sentence(new_data, stored_data) -- adds support for TSCs -- https://tatsumoto-ren.github.io/blog/discussing-various-card-templates.html#targeted-sentence-cards-or-mpvacious-cards -- if the target word was marked by yomichan, this function makes sure that the highlighting doesn't get erased. if is_empty(stored_data[config.sentence_field]) then -- sentence field is empty. can't continue. return new_data elseif is_empty(new_data[config.sentence_field]) then -- *new* sentence field is empty, but old one contains data. don't delete the existing sentence. new_data[config.sentence_field] = stored_data[config.sentence_field] return new_data end local _, opentag, target, closetag, _ = stored_data[config.sentence_field]:match('^(.-)(<[^>]+>)(.-)(]+>)(.-)$') if target then local prefix, _, suffix = new_data[config.sentence_field]:match(table.concat { '^(.-)(', target, ')(.-)$' }) if prefix and suffix then new_data[config.sentence_field] = table.concat { prefix, opentag, target, closetag, suffix } end end return new_data end local function audio_padding() local video_duration = mp.get_property_number('duration') if config.audio_padding == 0.0 or not video_duration then return 0.0 end if subs.user_timings.is_set('start') or subs.user_timings.is_set('end') then return 0.0 end return config.audio_padding end ------------------------------------------------------------ -- utility classes local function new_timings() local self = { ['start'] = -1, ['end'] = -1, } local is_set = function(position) return self[position] >= 0 end local set = function(position) self[position] = mp.get_property_number('time-pos') end local get = function(position) return self[position] end return { is_set = is_set, set = set, get = get, } end local function new_sub_list() local subs_list = {} local _is_empty = function() return next(subs_list) == nil end local find_i = function(sub) for i, v in ipairs(subs_list) do if sub < v then return i end end return #subs_list + 1 end local get_time = function(position) local i = position == 'start' and 1 or #subs_list return subs_list[i][position] end local get_text = function() local speech = {} for _, sub in ipairs(subs_list) do table.insert(speech, sub['text']) end return table.concat(speech, ' ') end local insert = function(sub) if sub ~= nil and not table.contains(subs_list, sub) then table.insert(subs_list, find_i(sub), sub) return true end return false end return { get_time = get_time, get_text = get_text, is_empty = _is_empty, insert = insert } end local function make_switch(states) local self = { states = states, current_state = 1 } local bump = function() self.current_state = self.current_state + 1 if self.current_state > #self.states then self.current_state = 1 end end local get = function() return self.states[self.current_state] end return { bump = bump, get = get } end local filename_factory = (function() local filename local anki_compatible_length = (function() -- Anki forcibly mutilates all filenames longer than 119 bytes when you run `Tools->Check Media...`. local allowed_bytes = 119 local timestamp_bytes = #'_99h99m99s999ms-99h99m99s999ms.webp' return function(str, timestamp) -- if timestamp provided, recalculate limit_bytes local limit_bytes = allowed_bytes - (timestamp and #timestamp or timestamp_bytes) if #str <= limit_bytes then return str end local bytes_per_char = contains_non_latin_letters(str) and #'車' or #'z' local limit_chars = math.floor(limit_bytes / bytes_per_char) if limit_chars == limit_bytes then return str:sub(1, limit_bytes) end local ret = subprocess { 'awk', '-v', string.format('str=%s', str), '-v', string.format('limit=%d', limit_chars), 'BEGIN{print substr(str, 1, limit); exit}' } if ret.status == 0 then ret.stdout = remove_newlines(ret.stdout) ret.stdout = remove_leading_trailing_spaces(ret.stdout) return ret.stdout else return 'subs2srs_' .. os.time() end end end)() local make_media_filename = function() filename = mp.get_property("filename") -- filename without path filename = remove_extension(filename) filename = remove_text_in_brackets(filename) filename = remove_special_characters(filename) end local make_audio_filename = function(speech_start, speech_end) local filename_timestamp = string.format( '_%s-%s%s', human_readable_time(speech_start), human_readable_time(speech_end), config.audio_extension ) return anki_compatible_length(filename, filename_timestamp) .. filename_timestamp end local make_snapshot_filename = function(timestamp) local filename_timestamp = string.format( '_%s%s', human_readable_time(timestamp), config.snapshot_extension ) return anki_compatible_length(filename, filename_timestamp) .. filename_timestamp end mp.register_event("file-loaded", make_media_filename) return { make_audio_filename = make_audio_filename, make_snapshot_filename = make_snapshot_filename, } end)() ------------------------------------------------------------ -- front for adding and updating notes local function export_to_anki(gui) local sub = subs.get() if sub == nil then notify("Nothing to export.", "warn", 1) return end if not gui and is_empty(sub['text']) then sub['text'] = string.format("mpvacious wasn't able to grab subtitles (%s)", os.time()) end local snapshot_timestamp = mp.get_property_number("time-pos", 0) local snapshot_filename = filename_factory.make_snapshot_filename(snapshot_timestamp) local audio_filename = filename_factory.make_audio_filename(sub['start'], sub['end']) encoder.create_snapshot(snapshot_timestamp, snapshot_filename) encoder.create_audio(sub['start'], sub['end'], audio_filename, audio_padding()) local note_fields = construct_note_fields(sub['text'], snapshot_filename, audio_filename) subs.clear() end local function update_last_note(overwrite) return end ------------------------------------------------------------ -- seeking: sub replay, sub seek, sub rewind local function _(params) return function() return pcall(unpack(params)) end end local pause_timer = (function() local stop_time = -1 local check_stop local set_stop_time = function(time) stop_time = time mp.observe_property("time-pos", "number", check_stop) end local stop = function() mp.unobserve_property(check_stop) stop_time = -1 end check_stop = function(_, time) if time > stop_time then stop() mp.set_property("pause", "yes") end end return { set_stop_time = set_stop_time, check_stop = check_stop, stop = stop, } end)() local play_control = (function() local current_sub local function stop_at_the_end(sub) pause_timer.set_stop_time(sub['end'] - 0.050) notify("Playing till the end of the sub...", "info", 3) end local function play_till_sub_end() local sub = subs.get_current() mp.commandv('seek', sub['start'], 'absolute') mp.set_property("pause", "no") stop_at_the_end(sub) end local function sub_seek(direction, pause) mp.commandv("sub_seek", direction == 'backward' and '-1' or '1') mp.commandv("seek", "0.015", "relative+exact") if pause then mp.set_property("pause", "yes") end pause_timer.stop() end local function sub_rewind() mp.commandv('seek', subs.get_current()['start'] + 0.015, 'absolute') pause_timer.stop() end local function check_sub() local sub = subs.get_current() if sub and sub ~= current_sub then mp.unobserve_property(check_sub) stop_at_the_end(sub) end end local function play_till_next_sub_end() current_sub = subs.get_current() mp.observe_property("sub-text", "string", check_sub) mp.set_property("pause", "no") notify("Waiting till next sub...", "info", 10) end return { play_till_sub_end = play_till_sub_end, play_till_next_sub_end = play_till_next_sub_end, sub_seek = sub_seek, sub_rewind = sub_rewind, } end)() ------------------------------------------------------------ -- platform specific local function init_platform_windows() local self = {} local curl_tmpfile_path = utils.join_path(os.getenv('TEMP'), 'curl_tmp.txt') mp.register_event('shutdown', function() os.remove(curl_tmpfile_path) end) self.tmp_dir = function() return os.getenv('TEMP') end self.copy_to_clipboard = function(text) text = text:gsub("&", "^^^&"):gsub("[<>|]", "") mp.commandv("run", "cmd.exe", "/d", "/c", string.format("@echo off & chcp 65001 >nul & echo %s|clip", text)) end self.curl_request = function(request_json, completion_fn) end self.windows = true return self end local function init_platform_nix() local self = {} local clip = is_running_macOS() and 'LANG=en_US.UTF-8 pbcopy' or is_running_wayland() and 'wl-copy' or 'xclip -i -selection clipboard' self.tmp_dir = function() return '/tmp' end self.copy_to_clipboard = function(text) local handle = io.popen(clip, 'w') handle:write(text) handle:close() end self.curl_request = function(request_json, completion_fn) end return self end platform = is_running_windows() and init_platform_windows() or init_platform_nix() ------------------------------------------------------------ -- utils for downloading pronunciations from Forvo do local base64d -- http://lua-users.org/wiki/BaseSixtyFour do local b = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/' base64d = function(data) data = string.gsub(data, '[^'..b..'=]', '') return (data:gsub('.', function(x) if (x == '=') then return '' end local r,f='',(b:find(x)-1) for i=6,1,-1 do r=r..(f%2^i-f%2^(i-1)>0 and '1' or '0') end return r; end):gsub('%d%d%d?%d?%d?%d?%d?%d?', function(x) if (#x ~= 8) then return '' end local c=0 for i=1,8 do c=c+(x:sub(i,i)=='1' and 2^(8-i) or 0) end return string.char(c) end)) end end local function url_encode(url) -- https://gist.github.com/liukun/f9ce7d6d14fa45fe9b924a3eed5c3d99 local char_to_hex = function(c) return string.format("%%%02X", string.byte(c)) end if url == nil then return end url = url:gsub("\n", "\r\n") url = url:gsub("([^%w _%%%-%.~])", char_to_hex) url = url:gsub(" ", "+") return url end local function reencode(source_path, dest_path) end local function reencode_and_store(source_path, filename) local reencoded_path = utils.join_path(platform.tmp_dir(), 'reencoded_' .. filename) reencode(source_path, reencoded_path) local result = ankiconnect.store_file(filename, reencoded_path) os.remove(reencoded_path) return result end local function curl_save(source_url, save_location) return true end local function get_pronunciation_url(word) end local function make_forvo_filename(word) return string.format('forvo_%s%s', platform.windows and os.time() or word, config.audio_extension) end local function get_forvo_pronunciation(word) local audio_url = get_pronunciation_url(word) if is_empty(audio_url) then msg.warn(string.format("Seems like Forvo doesn't have audio for word %s.", word)) return end local filename = make_forvo_filename(word) local tmp_filepath = utils.join_path(platform.tmp_dir(), filename) local result if curl_save(audio_url, tmp_filepath) and reencode_and_store(tmp_filepath, filename) then result = string.format('[sound:%s]', filename) else msg.warn(string.format("Couldn't download audio for word %s from Forvo.", word)) end os.remove(tmp_filepath) return result end append_forvo_pronunciation = function(new_data, stored_data) if config.use_forvo == 'no' then -- forvo functionality was disabled in the config file return new_data end if type(stored_data[config.vocab_audio_field]) ~= 'string' then -- there is no field configured to store forvo pronunciation return new_data end if is_empty(stored_data[config.vocab_field]) then -- target word field is empty. can't continue. return new_data end if config.use_forvo == 'always' or is_empty(stored_data[config.vocab_audio_field]) then local forvo_pronunciation = get_forvo_pronunciation(stored_data[config.vocab_field]) if not is_empty(forvo_pronunciation) then if config.vocab_audio_field == config.audio_field then -- improperly configured fields. don't lose sentence audio new_data[config.audio_field] = forvo_pronunciation .. new_data[config.audio_field] else new_data[config.vocab_audio_field] = forvo_pronunciation end end end return new_data end end ------------------------------------------------------------ -- AnkiConnect requests ankiconnect = {} ankiconnect.execute = function(request, completion_fn) -- utils.format_json returns a string -- On error, request_json will contain "null", not nil. local request_json, error = utils.format_json(request) if error ~= nil or request_json == "null" then return completion_fn and completion_fn() else return platform.curl_request(request_json, completion_fn) end end ankiconnect.parse_result = function(curl_output) return end ankiconnect.store_file = function(filename, file_path) return true end ankiconnect.create_deck = function(deck_name) local args = { action = "changeDeck", version = 6, params = { cards = {}, deck = deck_name } } local result_notify = function(_, result, _) return end ankiconnect.execute(args, result_notify) end ankiconnect.add_note = function(note_fields, gui) local action = gui and 'guiAddCards' or 'addNote' local tags = is_empty(config.note_tag) and {} or { substitute_fmt(config.note_tag) } local args = { action = action, version = 6, params = { note = { deckName = config.deck_name, modelName = config.model_name, fields = note_fields, options = { allowDuplicate = config.allow_duplicates, duplicateScope = "deck", }, tags = tags, } } } end ankiconnect.get_last_note_id = function() local ret = ankiconnect.execute { action = "findNotes", version = 6, params = { query = "added:1" -- find all notes added today } } end ankiconnect.get_note_fields = function(note_id) local ret = ankiconnect.execute { action = "notesInfo", version = 6, params = { notes = { note_id } } } end ankiconnect.gui_browse = function(query) ankiconnect.execute { action = 'guiBrowse', version = 6, params = { query = query } } end ankiconnect.add_tag = function(note_id, tag) if not is_empty(tag) then tag = substitute_fmt(tag) ankiconnect.execute { action = 'addTags', version = 6, params = { notes = { note_id }, tags = tag } } end end ankiconnect.append_media = function(note_id, fields, create_media_fn) -- AnkiConnect will fail to update the note if it's selected in the Anki Browser. -- https://github.com/FooSoft/anki-connect/issues/82 -- Switch focus from the current note to avoid it. ankiconnect.gui_browse("nid:1") -- impossible nid local args = { action = "updateNoteFields", version = 6, params = { note = { id = note_id, fields = fields, } } } local on_finish = function(_, result, _) return end ankiconnect.execute(args, on_finish) end ------------------------------------------------------------ -- subtitles and timings subs = { dialogs = new_sub_list(), user_timings = new_timings(), observed = false } subs.get_current = function() return Subtitle:now() end subs.get_timing = function(position) if subs.user_timings.is_set(position) then return subs.user_timings.get(position) elseif not subs.dialogs.is_empty() then return subs.dialogs.get_time(position) end return -1 end subs.get = function() if subs.dialogs.is_empty() then subs.dialogs.insert(subs.get_current()) end local sub = Subtitle:new { ['text'] = subs.dialogs.get_text(), ['start'] = subs.get_timing('start'), ['end'] = subs.get_timing('end'), } if sub['start'] < 0 or sub['end'] < 0 then return nil end if sub['start'] == sub['end'] then return nil end if sub['start'] > sub['end'] then sub['start'], sub['end'] = sub['end'], sub['start'] end if not is_empty(sub['text']) then sub['text'] = trim(sub['text']) sub['text'] = escape_special_characters(sub['text']) end return sub end subs.append = function() if subs.dialogs.insert(subs.get_current()) then menu.update() end end subs.observe = function() mp.observe_property("sub-text", "string", subs.append) subs.observed = true end subs.unobserve = function() mp.unobserve_property(subs.append) subs.observed = false end subs.set_timing = function(position) subs.user_timings.set(position) notify(capitalize_first_letter(position) .. " time has been set.") if not subs.observed then subs.observe() end end subs.set_starting_line = function() subs.clear() if subs.get_current() then subs.observe() notify("Timings have been set to the current sub.", "info", 2) else notify("There's no visible subtitle.", "info", 2) end end subs.clear = function() subs.unobserve() subs.dialogs = new_sub_list() subs.user_timings = new_timings() end subs.clear_and_notify = function() subs.clear() notify("Timings have been reset.", "info", 2) end ------------------------------------------------------------ -- send subs to clipboard as they appear clip_autocopy = (function() local enable = function() mp.observe_property("sub-text", "string", copy_to_clipboard) end local disable = function() mp.unobserve_property(copy_to_clipboard) end local state_notify = function() notify(string.format("Clipboard autocopy has been %s.", config.autoclip and 'enabled' or 'disabled')) end local toggle = function() config.autoclip = not config.autoclip if config.autoclip == true then enable() else disable() end state_notify() end local is_enabled = function() return config.autoclip == true and 'enabled' or 'disabled' end local init = function() if config.autoclip == true then enable() end end return { enable = enable, disable = disable, init = init, toggle = toggle, is_enabled = is_enabled, } end)() ------------------------------------------------------------ -- Subtitle class provides methods for comparing subtitle lines Subtitle = { ['text'] = '', ['start'] = -1, ['end'] = -1, } function Subtitle:new(o) o = o or {} setmetatable(o, self) self.__index = self return o end function Subtitle:now() local delay = mp.get_property_native("sub-delay") - mp.get_property_native("audio-delay") local text = mp.get_property("sub-text") local this = self:new { ['text'] = not is_empty(text) and text or "", ['start'] = mp.get_property_number("sub-start"), ['end'] = mp.get_property_number("sub-end"), } return this:valid() and this:delay(delay) or nil end function Subtitle:delay(delay) self['start'] = self['start'] + delay self['end'] = self['end'] + delay return self end function Subtitle:valid() return not is_empty(self['text']) and self['start'] and self['end'] and self['start'] >= 0 and self['end'] > 0 end Subtitle.__eq = function(lhs, rhs) return lhs['text'] == rhs['text'] end Subtitle.__lt = function(lhs, rhs) return lhs['start'] < rhs['start'] end ------------------------------------------------------------ -- main menu menu = { active = false, hints_state = make_switch { 'hidden', 'menu', 'global', }, overlay = nil; } menu.overlay_draw = function(text) menu.overlay.data = text menu.overlay:update() end menu.with_update = function(params) return function() pcall(unpack(params)) menu.update() end end menu.keybindings = { { key = 's', fn = menu.with_update { subs.set_timing, 'start' } }, { key = 'e', fn = menu.with_update { subs.set_timing, 'end' } }, { key = 'c', fn = menu.with_update { subs.set_starting_line } }, { key = 'r', fn = menu.with_update { subs.clear_and_notify } }, { key = 'g', fn = menu.with_update { export_to_anki, true } }, { key = 'n', fn = menu.with_update { export_to_anki, false } }, { key = 'm', fn = menu.with_update { update_last_note, false } }, { key = 'M', fn = menu.with_update { update_last_note, true } }, { key = 't', fn = menu.with_update { clip_autocopy.toggle } }, { key = 'i', fn = menu.with_update { menu.hints_state.bump } }, { key = 'p', fn = menu.with_update { load_next_profile } }, { key = 'ESC', fn = function() menu.close() end }, } menu.update = function() return end menu.open = function() if menu.overlay == nil then notify("OSD overlay is not supported in " .. mp.get_property("mpv-version"), "error", 5) return end if menu.active == true then menu.close() return end for _, val in pairs(menu.keybindings) do mp.add_forced_key_binding(val.key, val.key, val.fn) end menu.active = true menu.update() end menu.close = function() if menu.active == false then return end for _, val in pairs(menu.keybindings) do mp.remove_key_binding(val.key) end menu.overlay:remove() menu.active = false end ------------------------------------------------------------ -- main local main = (function() local main_executed = false return function() if main_executed then return else main_executed = true end config_manager.init(config, profiles) encoder.init(config, ankiconnect.store_file, platform.tmp_dir, subprocess) clip_autocopy.init() ensure_deck() -- Key bindings mp.add_forced_key_binding("Ctrl+n", "mpvacious-export-note", export_to_anki) mp.add_forced_key_binding("Ctrl+c", "mpvacious-copy-sub-to-clipboard", copy_sub_to_clipboard) mp.add_key_binding("Ctrl+t", "mpvacious-autocopy-toggle", clip_autocopy.toggle) -- Open advanced menu mp.add_key_binding("a", "mpvacious-menu-open", menu.open) -- Note updating mp.add_key_binding("Ctrl+m", "mpvacious-update-last-note", _ { update_last_note, false }) mp.add_key_binding("Ctrl+M", "mpvacious-overwrite-last-note", _ { update_last_note, true }) -- Vim-like seeking between subtitle lines mp.add_key_binding("H", "mpvacious-sub-seek-back", _ { play_control.sub_seek, 'backward' }) mp.add_key_binding("L", "mpvacious-sub-seek-forward", _ { play_control.sub_seek, 'forward' }) mp.add_key_binding("Alt+h", "mpvacious-sub-seek-back-pause", _ { play_control.sub_seek, 'backward', true }) mp.add_key_binding("Alt+l", "mpvacious-sub-seek-forward-pause", _ { play_control.sub_seek, 'forward', true }) mp.add_key_binding("Ctrl+h", "mpvacious-sub-rewind", _ { play_control.sub_rewind }) mp.add_key_binding("Ctrl+H", "mpvacious-sub-replay", _ { play_control.play_till_sub_end }) mp.add_key_binding("Ctrl+L", "mpvacious-sub-play-up-to-next", _ { play_control.play_till_next_sub_end }) end end)() mp.register_event("file-loaded", main)