--[[
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_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)