dotfiles2/base/.config/mpv/scripts/subs2srs.lua
2022-08-05 22:16:17 -07:00

1357 lines
40 KiB
Lua
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

--[[
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 <https://www.gnu.org/licenses/>.
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 <https://github.com/Ajatt-Tools/mpvacious/blob/master/README.md>
]]
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 = {
['&'] = '&amp;',
['"'] = '&quot;',
["'"] = '&apos;',
['<'] = '&lt;',
['>'] = '&gt;',
}
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('<img alt="snapshot" src="%s">', 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 "<PGS subtitles>",
['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)