├── mpv2anki_preview.jpg ├── LICENSE ├── README.md └── mpv2anki.lua /mpv2anki_preview.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/senneh/mpv2anki/HEAD/mpv2anki_preview.jpg -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Senne Hofman 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mvp2anki 2 | ![Preview](https://raw.githubusercontent.com/SenneH/mpv2anki/master/mpv2anki_preview.jpg) 3 | ## Overview 4 | Inspired by subs2srs this script lets mpv users quickly creates notes for Anki from sound or video fragments with minimal dependencies. 5 | ## Requirements 6 | * Linux (Windows *might* work, but is not officially supported) 7 | * [Anki](https://apps.ankiweb.net/) 8 | * The [AnkiConnect](https://ankiweb.net/shared/info/2055492159) plugin 9 | * curl (you should already have this) 10 | ## Installation 11 | 1. Save mpv2anki.lua in the mpv script folder (`~/.config/mpv/scripts/`). 12 | 2. Set the path to where Anki saves its media files in the config file (See Options below). 13 | ## Usage 14 | - Open a file in mpv and press `shift+f` to open the script menu. 15 | - Make sure Anki is also open. 16 | - Follow the onscreen instructions. 17 | - To disable audio/snapshots/subtitles simply do not enter a value. 18 | - Double pressing removes the current value. 19 | - `e` to open the Anki add card dialog box with the entered values or `shift+e` to add directly. 20 | 21 | ## Options 22 | Save as `mpv2anki.conf` in your script-opts folder (usually `~/.config/mpv/script-opts/`) 23 | 24 | ``` 25 | # This is the only required value. replace "user" and "profile" with your own. 26 | # This must be an absolute path. '~' for home dir will NOT work 27 | media_path=/home/user/.local/share/Anki2/profile/collection.media/ 28 | 29 | # These are the other options containing their default values. 30 | deckname=mpv2anki 31 | # The note type 32 | modelName=mpv2anki 33 | 34 | # You can use these options to remap the fields 35 | field_audio=audio 36 | field_snapshot=snapshot 37 | field_subtitle1=subtitle1 38 | field_subtitle2=subtitle2 39 | field_start_time=start_time 40 | field_end_time=end_time 41 | field_snapshot_time=snapshot_time 42 | field_title=title 43 | 44 | # The url and port AnkiConnect uses. This should be the default 45 | anki_url=localhost:8765 46 | 47 | # The font size used in the menu. 48 | font_size=20 49 | shortcut=shift+f 50 | 51 | # audio & snapshot options 52 | audio_bitrate=128k 53 | snapshot_height=480 54 | ``` 55 | -------------------------------------------------------------------------------- /mpv2anki.lua: -------------------------------------------------------------------------------- 1 | local util = require('mp.utils') 2 | local msg = require('mp.msg') 3 | local mpopt = require('mp.options') 4 | 5 | -- either modify your options here or create a config file in ~/.config/mpv/script-opts/mpv2anki.conf 6 | local options = { 7 | -- absolute path to where anki saves its media on linux this is usually the form of 8 | -- '/home/$user/.local/share/Anki2/$ankiprofile/collection.media/' 9 | -- relative paths (e.g. ~ for home dir) do NOT work. 10 | media_path = '', 11 | -- The anki deck where to put the cards 12 | deckname = 'mpv2anki', 13 | -- The note type 14 | modelName = 'mpv2anki', 15 | -- You can use these options to remap the fields 16 | field_audio = 'audio', 17 | field_snapshot = 'snapshot', 18 | field_subtitle1 = 'subtitle1', 19 | field_subtitle2 = 'subtitle2', 20 | field_start_time = 'start_time', 21 | field_end_time = 'end_time', 22 | field_snapshot_time = 'snapshot_time', 23 | field_title = 'title', 24 | -- The font size used in the menu. 25 | font_size = 20, 26 | shortcut = 'shift+f', 27 | -- In case you changed it 28 | anki_url = 'localhost:8765', 29 | audio_bitrate = '128k', 30 | snapshot_height = '480', 31 | } 32 | 33 | mpopt.read_options(options) 34 | 35 | local overlay = mp.create_osd_overlay('ass-events') 36 | 37 | local ctx = { 38 | start_time = -1, 39 | end_time = -1, 40 | snapshot_time = -1, 41 | sub = {'',''}, 42 | } 43 | 44 | ------------------------------------------------------------ 45 | -- utility functions 46 | 47 | local function time_to_string(seconds) 48 | if seconds < 0 then 49 | return 'empty' 50 | end 51 | local time = string.format('.%03d', seconds * 1000 % 1000); 52 | time = string.format('%02d:%02d%s', 53 | seconds / 60 % 60, 54 | seconds % 60, 55 | time) 56 | 57 | if seconds > 3600 then 58 | time = string.format('%02d:%s', seconds / 3600, time) 59 | end 60 | 61 | return time 62 | end 63 | 64 | local function time_to_string2(seconds) 65 | return string.format('%02dh%02dM%02ds%03dm', 66 | seconds / 3600, 67 | seconds / 60 % 60, 68 | seconds % 60, 69 | seconds * 1000 % 1000) 70 | end 71 | 72 | ------------------------------------------------------------ 73 | -- anki functions 74 | 75 | -- All anki requests are of the form: 76 | -- { 77 | -- "action" : @action 78 | -- "version" : 6 79 | -- "params" : @t_params (optional) 80 | -- } 81 | -- and return a lua table with 2 keys, "result" and "error" 82 | local function anki_connect(action, t_params, url) 83 | local url = url or 'localhost:8765' 84 | 85 | local request = { 86 | action = action, 87 | version = 6, 88 | } 89 | 90 | if t_params ~= nil then 91 | request.params = t_params 92 | end 93 | 94 | local json = util.format_json(request) 95 | 96 | local command = { 97 | 'curl', url, '-X', 'POST', '-d', json 98 | } 99 | 100 | local result = mp.command_native({ 101 | name = 'subprocess', 102 | args = command, 103 | capture_stdout = true, 104 | capture_stderr = true, 105 | }) 106 | 107 | result = util.parse_json(result.stdout) 108 | 109 | return result.result, result.error 110 | end 111 | 112 | ------------------------------------------------------------ 113 | -- creating media and anki note 114 | 115 | function generate_media_name() 116 | local name = mp.get_property('filename/no-ext') 117 | name = string.gsub(name, '[%[%]]', '') 118 | return 'mpv2anki_' .. name 119 | end 120 | 121 | -- Creates an audio fragment with @start_time and @end_time in @media_path. 122 | -- Returns a string containing the name to the audio file 123 | -- or an empty string on error 124 | function create_audio( 125 | media_path, 126 | filename_prefix, 127 | start_time, 128 | end_time, 129 | bitrate) 130 | 131 | if (start_time < 0 or end_time < 0) or 132 | (start_time == end_time) then 133 | return '' 134 | end 135 | 136 | if start_time > end_time then 137 | local t = start_time 138 | start_time = end_time 139 | end_time = t 140 | end 141 | 142 | local filename = string.format( 143 | '%s_(%s-%s).mp3', 144 | filename_prefix, 145 | time_to_string2(start_time), 146 | time_to_string2(end_time)) 147 | 148 | local encode_args = { 149 | 'mpv', mp.get_property('path'), 150 | '--start=' .. start_time, 151 | '--end=' .. end_time, 152 | '--aid=' .. mp.get_property("aid"), 153 | '--vid=no', 154 | '--loop-file=no', 155 | '--oacopts=b=' .. bitrate, 156 | '-o=' .. media_path .. filename 157 | } 158 | 159 | local result = mp.command_native({ 160 | name = 'subprocess', 161 | args = encode_args, 162 | capture_stdout = true, 163 | capture_stderr = true, 164 | }) 165 | 166 | return filename 167 | end 168 | 169 | -- Takes a snapshot at @snapshot_time and writes it to @media_path 170 | -- Returns a string containing the name to the snapshot 171 | -- or an empty string on error 172 | function create_snapshot(media_path, filename_prefix, snapshot_time, height) 173 | if (snapshot_time <= 0) then 174 | return '' 175 | end 176 | 177 | local filename = string.format( 178 | '%s_(%s).jpg', 179 | filename_prefix, 180 | time_to_string2(snapshot_time) 181 | ) 182 | 183 | -- Sadly the screenshot command does not allow us to create screenshots on specific 184 | -- times nor resize images. So we have to encode to a single image instead 185 | local encode_args = { 186 | 'mpv', mp.get_property('path'), 187 | '-start=' .. snapshot_time, 188 | '--frames=1', 189 | '--no-audio', 190 | '--no-sub', 191 | '--vf-add=scale=-2:' .. height, 192 | -- See https://github.com/mpv-player/mpv/issues/6088 193 | -- This is the equivalent of --ovcopts=qscale=2 194 | '--ovcopts=global_quality=2*QP2LAMBDA,flags=+qscale', 195 | '--loop-file=no', 196 | '-o=' .. media_path .. filename 197 | } 198 | 199 | local result = mp.command_native({ 200 | name = 'subprocess', 201 | args = encode_args, 202 | capture_stdout = true, 203 | capture_stderr = true, 204 | }) 205 | 206 | return filename 207 | end 208 | 209 | function create_anki_note(gui) 210 | 211 | local filename_prefix = generate_media_name() 212 | 213 | local filename_audio = create_audio( 214 | options.media_path, 215 | filename_prefix, 216 | ctx.start_time, 217 | ctx.end_time, 218 | options.audio_bitrate) 219 | 220 | local filename_snapshot = create_snapshot( 221 | options.media_path, 222 | filename_prefix, 223 | ctx.snapshot_time, 224 | options.snapshot_height 225 | ) 226 | 227 | -- Start filling the fields 228 | local fields = {} 229 | 230 | if #filename_audio > 0 then 231 | fields[options.field_audio] = '[sound:'..filename_audio..']' 232 | fields[options.field_start_time] = time_to_string(ctx.start_time) 233 | fields[options.field_end_time] = time_to_string(ctx.end_time) 234 | end 235 | 236 | if #filename_snapshot > 0 then 237 | fields[options.field_snapshot] = '' 238 | fields[options.field_snapshot_time] = time_to_string(ctx.snapshot_time) 239 | end 240 | 241 | if #ctx.sub[1] > 0 then 242 | fields[options.field_subtitle1] = ctx.sub[1] 243 | end 244 | 245 | if #ctx.sub[2] > 0 then 246 | fields[options.field_subtitle2] = ctx.sub[2] 247 | end 248 | 249 | fields[options.field_title] = mp.get_property('filename/no-ext') 250 | 251 | local param = { 252 | note = { 253 | deckName = options.deckname, 254 | modelName = options.modelName, 255 | fields = fields, 256 | tags = { 257 | 'mpv2anki' 258 | } 259 | } 260 | } 261 | 262 | local action; 263 | 264 | if gui then 265 | action = 'guiAddCards' 266 | else 267 | action = 'addNote' 268 | end 269 | 270 | anki_connect(action, param, options.anki_url) 271 | end 272 | 273 | ------------------------------------------------------------ 274 | -- main menu 275 | 276 | local menu_keybinds = { 277 | { key = '1', fn = function() menu_set_time('start_time', 'time-pos') end }, 278 | { key = '2', fn = function() menu_set_time('end_time', 'time-pos') end }, 279 | { key = '3', fn = function() menu_set_time('snapshot_time', 'time-pos') end }, 280 | { key = '4', fn = function() menu_set_subs(1) end }, 281 | { key = '5', fn = function() menu_set_subs(2) end }, 282 | { key = 'd', fn = function() menu_set_to_subs() end }, 283 | { key = 'D', fn = function() menu_clear_all() end }, 284 | { key = 'S', fn = function() menu_set_time('end_time', 'sub-end') end }, 285 | { key = 's', fn = function() menu_set_time('start_time', 'sub-start') end }, 286 | { key = 'a', fn = function() menu_append(1) end }, 287 | { key = 'A', fn = function() menu_append(2) end }, 288 | { key = 'e', fn = function() create_anki_note(true) end }, 289 | { key = 'shift+e', fn = function() create_anki_note(false) end }, 290 | { key = 'ESC', fn = function() menu_close() end }, 291 | } 292 | 293 | function menu_append(n) 294 | local subs = mp.get_property('sub-text'):gsub('\n', '') 295 | 296 | ctx.sub[n] = ctx.sub[n] .. ' ' .. subs 297 | 298 | menu_update() 299 | end 300 | 301 | function menu_set_to_subs() 302 | local start_time = mp.get_property_number('sub-start') 303 | local end_time = mp.get_property_number('sub-end') 304 | 305 | if start_time == nil then 306 | start_time = -1 307 | end 308 | 309 | if end_time == nil then 310 | end_time = -1 311 | end 312 | 313 | ctx.start_time = start_time 314 | ctx.end_time = end_time 315 | ctx.sub[1] = mp.get_property('sub-text'):gsub('\n', '') 316 | 317 | menu_update() 318 | end 319 | 320 | function menu_set_time(field, prop) 321 | local time = mp.get_property_number(prop) 322 | 323 | if time == nil or time == ctx[field] then 324 | ctx[field] = -1 325 | else 326 | ctx[field] = time 327 | end 328 | 329 | menu_update() 330 | end 331 | 332 | function menu_set_subs(n) 333 | local subs = mp.get_property('sub-text'):gsub('\n', '') 334 | 335 | if subs == ctx.sub[n] then 336 | ctx.sub[n] = '' 337 | else 338 | ctx.sub[n] = subs 339 | end 340 | menu_update() 341 | end 342 | 343 | function menu_clear_all() 344 | ctx.start_time = -1 345 | ctx.end_time = -1 346 | ctx.snapshot_time = -1 347 | ctx.sub[1] = '' 348 | ctx.sub[2] = '' 349 | 350 | menu_update() 351 | end 352 | 353 | 354 | function menu_update() 355 | local ass = ASS.new():s(options.font_size):b('MPV2Anki'):nl():nl() 356 | 357 | -- Media type 358 | ass:b('Audio fragment'):nl() 359 | 360 | ass:tab():b('1: ') 361 | :a('Set start time (' .. time_to_string(ctx.start_time) .. ')'):nl() 362 | ass:tab():b('2: ') 363 | :a('Set end time (' .. time_to_string(ctx.end_time) .. ')'):nl() 364 | :nl() 365 | 366 | -- snapshot 367 | ass:b('Snapshot'):nl() 368 | 369 | ass:tab():b('3: ') 370 | :a('Set snapshot (' .. time_to_string(ctx.snapshot_time) .. ')'):nl() 371 | :nl() 372 | 373 | -- subtitle 374 | ass:b('Subtitles'):nl() 375 | local start_key = 4 376 | for i, sub in pairs(ctx.sub) do 377 | ass:tab():b(start_key .. ': ') 378 | :a('sub ' .. i) 379 | if sub == '' then sub = 'empty' end 380 | ass:a(' (' .. sub .. ')'):nl() 381 | start_key = start_key + 1 382 | end 383 | 384 | -- menu options 385 | ass:nl() 386 | :b('d: '):a('Set to current subtitle ('):b('D: '):a('clear all)'):nl() 387 | :b('s: '):a('Set end time to sub ('):b('S: '):a('begin time)'):nl() 388 | :b('a: '):a('Append sub 1 ('):b('A: '):a('sub 2)'):nl() 389 | :b('e: '):a('Create card ('):b('E: '):a('without GUI)'):nl() 390 | :b('ESC: '):a('Close'):nl() 391 | 392 | ass:draw() 393 | end 394 | 395 | function menu_close() 396 | for _, val in pairs(menu_keybinds) do 397 | mp.remove_key_binding(val.key) 398 | end 399 | overlay:remove() 400 | end 401 | 402 | 403 | function menu_open() 404 | for _, val in pairs(menu_keybinds) do 405 | mp.add_key_binding(val.key, val.key, val.fn) 406 | end 407 | menu_update() 408 | end 409 | 410 | ------------------------------------------------------------ 411 | -- Helper functions for styling ASS messages 412 | 413 | ASS = {} 414 | ASS.__index = ASS 415 | 416 | function ASS.new() 417 | return setmetatable({text=''}, ASS) 418 | end 419 | 420 | -- append 421 | function ASS:a(s) 422 | self.text = self.text .. s 423 | return self 424 | end 425 | 426 | -- bold 427 | function ASS:b(s) 428 | return self:a('{\\b1}' .. s .. '{\\b0}') 429 | end 430 | 431 | -- new line 432 | function ASS:nl() 433 | return self:a('\\N') 434 | end 435 | 436 | -- 4 space tab 437 | function ASS:tab() 438 | return self:a('\\h\\h\\h\\h') 439 | end 440 | 441 | -- size 442 | function ASS:s(size) 443 | return self:a('{\\fs' .. size .. '}') 444 | end 445 | 446 | function ASS:draw() 447 | overlay.data = self.text 448 | overlay:update() 449 | end 450 | 451 | ------------------------------------------------------------ 452 | -- Finally, set an 'entry point' in mpv 453 | 454 | mp.add_key_binding(options.shortcut, options.shortcut, menu_open) 455 | --------------------------------------------------------------------------------