--------- Current Status -------
-- Outdated with the appearance of the mpv2anki add-on - https://ankiweb.net/shared/info/1213145732
--------------------------------

--------- Installation ---------
-- 1. Install Anki (https://apps.ankiweb.net) & mpv (https://mpv.io) video player (or IINA for macOS).
-- 2. Install FFmpeg.
--      FFmpeg for Windows can be downloaded from http://ffmpeg.zeranoe.com
--      FFmpeg for macOS can be installed using brew (https://brew.sh): brew install ffmpeg --with-libvpx --with-opus
-- 3. Install Python 2.7 (it's probably already installed in macOS & Linux) and 'requests' module using pip: pip install requests
-- 3. Install Anki Connect add-on - https://ankiweb.net/shared/info/2055492159
-- 4. Import subs2srs-sample.apkg in Anki.
-- 5. Copy this file (subs2srs.lua) and subs2srs.py in the 'scripts' subdirectory of the mpv configuration directory.
--      Default path:
--      - Linux & macOS: ~/.config/mpv/scripts
--      - Windows: {mpv installation folder}/scripts (create it if it doesn't exist)
-- 6. And update at least the following settings:
--      LUA_SCRIPTS_DIRECTORY
--      COLLECTION_MEDIA_DIR
--    For Windows also update these settings if python.exe and ffmpeg.exe aren't in PATH environment variable:
--      FFMPEG_PATH
--      PYTHON_PATH
--------------------------------

------------ Usage -------------
-- 1. Open Anki.
-- 2. Open any video file with .srt subtitles alongside it in mpv video player.
-- 3. Navigate to the scene with subtitles.
-- 4. Press 'b' to add new card in Anki.
--------------------------------

-------- Sample Deck -----------
-- It contains two note types (subs2srs and subs2srs-video) and 3 types of cards (audio, cloze, video).
-- Audio & Video card template is from substudy (http://www.randomhacks.net/substudy), except there's no subtitles in the native language.

-- Note: subs2srs-video note type requires Anki Beta, NOTE_TYPE updated, EXPORT_VIDEO to be set to true.
-- By default video tag in subs2srs-video card template doesn't contain controls attribute and the video can be replayed only by 
-- clicking inside the card (setting focus) and presssing Shift + R or Ctrl + R.
-- That's why it's better to install "Refocus Card when Reviewing" Anki add-on, but Anki Beta won't install it from AnkiWeb.
-- So download it using a stable version of Anki or using this link - https://gist.github.com/kelciour/a3329225d2f7fce48bf24af92af5f4fe
-- and install it using this manual - https://apps.ankiweb.net/docs/addons21.html#single-.py-add-ons-need-their-own-folder
--------------------------------

------- Default Hotkeys --------
-- b - add new card (using current subtitle timing or start & end time if they are set)
-- w - set start time (and automatically set end time if it's not set)
-- e - set end time (and automatically set start time if it's not set)
-- Ctrl + w - preview the card (and set start & end time if it's necessary)
-- Ctrl + e - replay the last n seconds of the card (and set start & end time if it's necessary)
-- Ctrl + b - reset start & end time to the current subtitle timing

-- To change default script hotkeys update the bottom of this script.

-- Some of mpv default keybindings:
--   Left and Right - seek backward/forward 5 seconds.
--   Shift + Left and Shift + Right - seek backward/forward 1 second.
--   , and . - step backward/forward 1 frame (probably works only if current keyboard layout is English).
-- For more info: https://mpv.io/manual/master/#keyboard-control
--------------------------------

------- Script Settings --------
-- Subtitles in the target language.
SRT_FILE_EXTENSIONS = {".srt", ".en.srt", ".eng.srt"}

-- Path to the mpv 'scripts' folder. Default: ~/.config/mpv/scripts
LUA_SCRIPTS_DIRECTORY = [[C:\Programs\mpv\scripts]]

-- Path to the subs2srs.py.
PYTHON_HELPER_PATH = LUA_SCRIPTS_DIRECTORY .. "/" .. "subs2srs.py"

-- Pad the start and end times when generating an audio clip.
-- For example, setting the padding to 0.25 means that the audio clip 
-- will start 250 milliseconds sooner and will end 250 milliseconds later than it would normally.
AUDIO_CLIP_PADDING = 1.25

-- Fade the start and the end of the audio when generating an audio clip.
AUDIO_CLIP_FADE = 0.2

-- It's better to set it false in case of non-latin subtitles.
JOIN_SUBTITLES_INTO_SENTENCES = true

-- Replay the last n seconds of the anki card.
REPLAY_TIME = 1.5
--------------------------------

-------- Anki Settings ---------
-- Path to the collection.media folder. How to find it - https://apps.ankiweb.net/docs/manual.html#files
COLLECTION_MEDIA_DIR = [[C:\Users\Nickolay\AppData\Roaming\Anki2\subs2srs\collection.media]]

-- Name of the note type that contains fields Sound, Time, Source, Image, Target: line, Target: line before, Target: line after.
NOTE_TYPE = [[subs2srs]]

-- Name of the deck where cards will be added. If the deck doesn't exist it will be created.
DEFAULT_DECK = [[subs2srs]]

-- If this option is true then cards will be added in the subdeck with the same name as the name of the video.
CREATE_SUBDECKS_FOR_CARDS = true
--------------------------------

-------- Image Settings --------
-- Width and Height of the generated snapshots (in pixels).
-- To keep the aspect ratio specify only one component, either width or height, and set the other component to -1.
-- If both width and height set to -1 the generated snapshots won't be resized.
IMAGE_WIDTH = 400
IMAGE_HEIGHT = -1

-- Save the video image with subtitles (if they're visible), in its original resolution,
-- i.e. options IMAGE_WIDTH and IMAGE_HEIGHT won't be applied.
IMAGE_WITH_SUBTITLES = false
--------------------------------

-------- Video Settings --------
VIDEO_WIDTH = 480
VIDEO_HEIGHT = -2

VIDEO_FORMAT_HTML5 = true

EXPORT_VIDEO = false
--------------------------------

-------- Other Settings --------
-- For Windows either update PATH environment variable or replace with
-- the absolute path to the ffmpeg.exe and python.exe, for example, [[C:\Programs\ffmpeg\bin\ffmpeg.exe]]
FFMPEG_PATH = [[ffmpeg]]
PYTHON_PATH = [[python]]
--------------------------------

utils = require 'mp.utils'

function srt_time_to_seconds(time)
    major, minor = time:match("(%d%d:%d%d:%d%d),(%d%d%d)")
    hours, mins, secs = major:match("(%d%d):(%d%d):(%d%d)")
    return hours * 3600 + mins * 60 + secs + minor / 1000
end

function seconds_to_time(time, delimiter)
    hours = math.floor(time / 3600)
    mins = math.floor(time / 60) % 60
    secs = math.floor(time % 60)
    milliseconds = (time * 1000) % 1000

    return string.format("%02d:%02d:%02d%s%03d", hours, mins, secs, delimiter, milliseconds)
end

function seconds_to_ffmpeg_time(time)
    return seconds_to_time(time, '.')
end

function set_start_timestamp()
    start_timestamp = mp.get_property_number("time-pos") + AUDIO_CLIP_PADDING

    if end_timestamp == nil then
        local sub_start, sub_end = get_current_sub_timing()
        end_timestamp = sub_end + AUDIO_CLIP_PADDING
    end

    mp.osd_message("Start: " .. seconds_to_ffmpeg_time(start_timestamp))
end

function set_end_timestamp()
    end_timestamp = mp.get_property_number("time-pos") - AUDIO_CLIP_PADDING
    
    if start_timestamp == nil then
        local sub_start, sub_end = get_current_sub_timing()
        start_timestamp = sub_start - AUDIO_CLIP_PADDING
    end

    mp.osd_message("End: " .. seconds_to_ffmpeg_time(end_timestamp))
end

function get_current_sub_timing()
    local time_pos = mp.get_property_number("time-pos")

    local sub_start, sub_end
    for i, sub_text in ipairs(sentences) do
        sub_start = sentences_start[i]
        sub_end = sentences_end[i]

        if sub_start <= time_pos and time_pos <= sub_end then
            break
        end

        if sentences_start[i] > time_pos then
            break
        end
    end

    return sub_start, sub_end
end

function set_start_end_timestamps()
    local time_pos = mp.get_property_number("time-pos")

    for i, sub_text in ipairs(sentences) do
        start_timestamp = sentences_start[i]
        end_timestamp = sentences_end[i]

        if i < #sentences and sentences_start[i+1] > time_pos then
            break
        end
    end

    mp.osd_message(string.format("%s - %s", seconds_to_ffmpeg_time(start_timestamp), seconds_to_ffmpeg_time(end_timestamp)))
end

function pause_player()
    mp.set_property("pause", "yes")
end

function replay_anki_card()
    if start_timestamp == nil and end_timestamp == nil then
        local subtitles_fragment, subtitles_fragment_start_time, subtitles_fragment_end_time, subtitles_fragment_prev, subtitles_fragment_next = get_subtitles_fragment(sentences, sentences_start, sentences_end)
    
        if subtitles_fragment == "" then
            mp.osd_message("no text")
            return
        else
            start_timestamp = subtitles_fragment_start_time
            end_timestamp = subtitles_fragment_end_time
        end
    end

    mp.commandv("seek", start_timestamp - AUDIO_CLIP_PADDING, "absolute+exact")
    mp.set_property("pause", "no")
    player_state = "replay from the start"
end

function replay_the_last_seconds_of_anki_card()
    if start_timestamp == nil and end_timestamp == nil then
        local subtitles_fragment, subtitles_fragment_start_time, subtitles_fragment_end_time, subtitles_fragment_prev, subtitles_fragment_next = get_subtitles_fragment(sentences, sentences_start, sentences_end)
    
        if subtitles_fragment == "" then
            mp.osd_message("no text")
            return
        else
            start_timestamp = subtitles_fragment_start_time
            end_timestamp = subtitles_fragment_end_time
        end
    end

    mp.commandv("seek", end_timestamp - REPLAY_TIME, "absolute+exact")
    mp.set_property("pause", "no")
    player_state = "replay from the end"
end

function on_playback_restart()
    if timer ~= nil then
        timer:kill()
    end

    if player_state == "replay from the end" then
        timer = mp.add_timeout(REPLAY_TIME + AUDIO_CLIP_PADDING, pause_player)
        player_state = nil
    elseif player_state == "replay from the start" then
        timer = mp.add_timeout(end_timestamp - start_timestamp + 2 * AUDIO_CLIP_PADDING, pause_player)
        player_state = nil
    end
end

function open_subtitles_file(srt_file_exts)
    local srt_filename = mp.get_property("working-directory") .. "/" .. mp.get_property("filename/no-ext")
    
    for i, ext in ipairs(srt_file_exts) do
        local f, err = io.open(srt_filename .. ext, "r")
        
        if f then
            return f
        end
    end

    return false
end

function read_subtitles(srt_file_exts)
    local f = open_subtitles_file(srt_file_exts)

    if not f then
        return false
    end

    local data = f:read("*all")
    data = string.gsub(data, "\r\n", "\n")
    f:close()
    
    subs = {}
    subs_start = {}
    subs_end = {}
    for start_time, end_time, text in string.gmatch(data, "(%d%d:%d%d:%d%d,%d%d%d) %-%-> (%d%d:%d%d:%d%d,%d%d%d)\n(.-)\n\n") do
      table.insert(subs, text)
      table.insert(subs_start, srt_time_to_seconds(start_time))
      table.insert(subs_end, srt_time_to_seconds(end_time))
    end

    return true
end

function convert_into_sentences()
    sentences = {}
    sentences_start = {}
    sentences_end = {}

    for i, sub_text in ipairs(subs) do
        sub_start = subs_start[i]
        sub_end = subs_end[i]
        sub_text = string.gsub(sub_text, "<[^>]+>", "")

        if sub_text:find("^- ") ~= nil and sub_text:sub(3,3) ~= sub_text:sub(3,3):upper() then
           sub_text = string.gsub(sub_text, "^- ", "")
        end

        if #sentences > 0 then
            prev_sub_start = sentences_start[#sentences]
            prev_sub_end = sentences_end[#sentences]
            prev_sub_text = sentences[#sentences]

            if JOIN_SUBTITLES_INTO_SENTENCES and (sub_start - prev_sub_end) <= 2 and sub_text:sub(1,1) ~= '-' and 
                    sub_text:sub(1,1) ~= '"' and sub_text:sub(1,1) ~= "'" and sub_text:sub(1,1) ~= '(' and
                    (prev_sub_text:sub(prev_sub_text:len()) ~= "." or prev_sub_text:sub(prev_sub_text:len()-2) == "...") and
                    prev_sub_text:sub(prev_sub_text:len()) ~= "?" and prev_sub_text:sub(prev_sub_text:len()) ~= "!" and
                    (sub_text:sub(1,1) == sub_text:sub(1,1):lower() or prev_sub_text:sub(prev_sub_text:len()) == ",") then
                local text = prev_sub_text .. " " .. sub_text
                text = string.gsub(text, "\n", "#")
                text = string.gsub(text, "%.%.%. %.%.%.", " ")
                text = string.gsub(text, "#%-", "\n-")
                text = string.gsub(text, "#", " ")
                if text:match("\n%-") ~= nil and text:match("^%-") == nil then
                    text = "- " .. text
                end
                
                sentences[#sentences] = text
                sentences_end[#sentences] = sub_end
            else
                table.insert(sentences, sub_text)
                table.insert(sentences_start, sub_start)
                table.insert(sentences_end, sub_end)    
            end
        else
            table.insert(sentences, sub_text)
            table.insert(sentences_start, sub_start)
            table.insert(sentences_end, sub_end)
        end
    end
end

function export_video_fragment(start_timestamp, end_timestamp)
    if EXPORT_VIDEO == false then
        return ""
    end

    local video_input = mp.get_property("working-directory") .. "/" .. mp.get_property("filename")
    local video_filename = mp.get_property("filename/no-ext")

    local ff_aid = 0
    local tracks_count = mp.get_property_number("track-list/count")
    for i = 1, tracks_count do
        local track_type = mp.get_property(string.format("track-list/%d/type", i))
        local track_index = mp.get_property_number(string.format("track-list/%d/ff-index", i))
        local track_selected = mp.get_property(string.format("track-list/%d/selected", i))

        if track_type == "audio" and track_selected == "yes" then
            ff_aid = track_index - 1
            break
        end
    end

    start_timestamp = start_timestamp - AUDIO_CLIP_PADDING
    end_timestamp = end_timestamp + AUDIO_CLIP_PADDING

    d = AUDIO_CLIP_FADE
    t = end_timestamp - start_timestamp

    local clip_filename = table.concat{
        video_filename,
        "_",
        string.format("%.3f", start_timestamp),
        "-",
        string.format("%.3f", end_timestamp)
    }

    local clip_output, args
    if VIDEO_FORMAT_HTML5 ~= true then
        clip_filename = clip_filename .. ".mp4"
        clip_output = COLLECTION_MEDIA_DIR .. "/" .. clip_filename
        args = {
            FFMPEG_PATH,
            "-y",
            "-ss", start_timestamp,
            "-i", video_input,
            "-t", string.format("%.3f", t),
            "-map", "0:v:0",
            "-map", string.format("0:a:%d", ff_aid),
            "-af", string.format("afade=t=in:curve=ipar:st=%.3f:d=%.3f,afade=t=out:curve=ipar:st=%.3f:d=%.3f", 0, d, t - d, d),
            "-c:v", "libx264",
            "-preset", "medium",
            "-c:a", "aac",
            "-ac", "2",
            clip_output
        }
    else
        clip_filename = clip_filename .. ".webm"
        clip_output = COLLECTION_MEDIA_DIR .. "/" .. clip_filename
        args = {
            FFMPEG_PATH,
            "-y",
            "-ss", start_timestamp,
            "-i", video_input,
            "-t", string.format("%.3f", t),
            "-map", "0:v:0",
            "-map", string.format("0:a:%d", ff_aid),
            "-af", string.format("afade=t=in:curve=ipar:st=%.3f:d=%.3f,afade=t=out:curve=ipar:st=%.3f:d=%.3f", 0, d, t - d, d),
            "-c:v", "libvpx-vp9",
            "-b:v", "1400K",
            "-threads", "8",
            "-speed", "2",
            "-crf", "23",
            "-c:a", "libopus",
            "-b:a", "64k",
            "-ac", "2",
            clip_output
        }
    end

    if VIDEO_WIDTH == -1 then VIDEO_WIDTH = -2 end
    if VIDEO_HEIGHT == -1 then VIDEO_HEIGHT = -2 end

    if VIDEO_WIDTH ~= -2 or VIDEO_HEIGHT ~= -2 then
        table.insert(args, #args, "-vf")
        table.insert(args, #args, string.format('scale=%d:%d', VIDEO_WIDTH, VIDEO_HEIGHT))
    end

    utils.subprocess_detached({ args = args })

    return clip_filename
end


function export_audio_fragment(start_timestamp, end_timestamp)
    local video_input = mp.get_property("working-directory") .. "/" .. mp.get_property("filename")
    local video_filename = mp.get_property("filename/no-ext")

    local ff_aid = 0
    local tracks_count = mp.get_property_number("track-list/count")
    for i = 1, tracks_count do
        local track_type = mp.get_property(string.format("track-list/%d/type", i))
        local track_index = mp.get_property_number(string.format("track-list/%d/ff-index", i))
        local track_selected = mp.get_property(string.format("track-list/%d/selected", i))

        if track_type == "audio" and track_selected == "yes" then
            ff_aid = track_index - 1
            break
        end
    end

    start_timestamp = start_timestamp - AUDIO_CLIP_PADDING
    end_timestamp = end_timestamp + AUDIO_CLIP_PADDING

    d = AUDIO_CLIP_FADE
    t = end_timestamp - start_timestamp

    local audio_filename = table.concat{
        video_filename,
        "_",
        string.format("%.3f", start_timestamp),
        "-",
        string.format("%.3f", end_timestamp),
        ".mp3"
    }

    local audio_output = COLLECTION_MEDIA_DIR .. "/" .. audio_filename

    local args = {
        FFMPEG_PATH,
        "-y",
        "-ss", start_timestamp,
        "-i", video_input,
        "-t", string.format("%.3f", t),
        "-map", string.format("0:a:%d", ff_aid),
        "-af", string.format("afade=t=in:curve=ipar:st=%.3f:d=%.3f,afade=t=out:curve=ipar:st=%.3f:d=%.3f", 0, d, t - d, d),
        audio_output
    }

    utils.subprocess_detached({ args = args })

    return audio_filename
end

function export_screenshot()
    local time_pos = mp.get_property_number("time-pos")
    
    local video_input = mp.get_property("working-directory") .. "/" .. mp.get_property("filename")
    
    local image_filename = string.format("%s_%.3f.jpg", mp.get_property("filename/no-ext"), time_pos)
    local image_output = COLLECTION_MEDIA_DIR .. "/" .. image_filename

    if IMAGE_WITH_SUBTITLES then
        mp.commandv("screenshot-to-file", image_output)
    else
        local args = {FFMPEG_PATH, '-y', '-ss', seconds_to_ffmpeg_time(time_pos), '-i', video_input, "-vframes", "1", "-q:v", "2", image_output}
        
        if IMAGE_WIDTH ~= -1 or IMAGE_HEIGHT ~= -1 then
            table.insert(args, #args, "-vf")
            table.insert(args, #args, string.format('scale=%d:%d', IMAGE_WIDTH, IMAGE_HEIGHT))
        end

        utils.subprocess_detached({args = args})
    end

    return image_filename
end

function get_subtitles_fragment(subtitles, subtitles_start, subtitles_end)
    local time_pos = mp.get_property_number("time-pos")

    local subtitles_fragment = {}
    local subtitles_fragment_start_time = nil
    local subtitles_fragment_end_time = nil
    local subtitles_fragment_prev = " "
    local subtitles_fragment_next = " "

    if start_timestamp ~= nil then
        start_timestamp = start_timestamp - AUDIO_CLIP_PADDING
        end_timestamp = end_timestamp + AUDIO_CLIP_PADDING
    end

    for i, sub_text in ipairs(subtitles) do
        local sub_start = subtitles_start[i]
        local sub_end = subtitles_end[i]

        if (start_timestamp ~= nil and (start_timestamp <= sub_start and sub_end <= end_timestamp)) or
            (start_timestamp == nil and (sub_start <= time_pos and time_pos <= sub_end))
        then
            table.insert(subtitles_fragment, sub_text)

            if subtitles_fragment_start_time == nil then
                subtitles_fragment_start_time = sub_start
                
                if i ~= 1 then
                    subtitles_fragment_prev = subtitles[i-1]
                end
            end

            subtitles_fragment_end_time = sub_end

            if i ~= #subtitles then
                subtitles_fragment_next = subtitles[i+1]
            end
        end

        if end_timestamp ~= nil then
            if sub_start > end_timestamp then
                break
            end
        elseif sub_start > time_pos then
            break
        end
    end

    if start_timestamp ~= nil then
        start_timestamp = start_timestamp + AUDIO_CLIP_PADDING
        end_timestamp = end_timestamp - AUDIO_CLIP_PADDING
    end

    if start_timestamp ~= nil then
        subtitles_fragment_start_time = start_timestamp
        subtitles_fragment_end_time = end_timestamp
    end

    return table.concat(subtitles_fragment, "<br />"), subtitles_fragment_start_time, subtitles_fragment_end_time, subtitles_fragment_prev, subtitles_fragment_next
end

function create_anki_card()
    local subtitles_fragment, subtitles_fragment_start_time, subtitles_fragment_end_time, subtitles_fragment_prev, subtitles_fragment_next = get_subtitles_fragment(sentences, sentences_start, sentences_end)
    
    if subtitles_fragment == "" then
        mp.osd_message("no text")
        return
    end

    local video_filename = mp.get_property("filename/no-ext")
    local audio_filename = export_audio_fragment(subtitles_fragment_start_time, subtitles_fragment_end_time)
    local clip_filename = export_video_fragment(subtitles_fragment_start_time, subtitles_fragment_end_time)
    local image_filename = export_screenshot()


    local deck = DEFAULT_DECK
    if CREATE_SUBDECKS_FOR_CARDS then
        deck = deck .. "::" .. video_filename
    end

    local sound = "[sound:" .. audio_filename .. "]"
    local video = clip_filename
    local time = seconds_to_ffmpeg_time(subtitles_fragment_start_time)
    local source = video_filename
    local image = "<img src=\"" .. image_filename .. "\" />"
    local target_line = subtitles_fragment
    local target_line_before = subtitles_fragment_prev
    local target_line_after = subtitles_fragment_next

    local fields = {}
    fields["Sound"] = sound
    fields["Time"] = time
    fields["Source"] = source
    fields["Image"] = image
    fields["Target: line"] = target_line
    fields["Target: line before"] = target_line_before
    fields["Target: line after"] = target_line_after
    if EXPORT_VIDEO then
        fields["Video"] = video
    end

    local args = {PYTHON_PATH, PYTHON_HELPER_PATH, deck, NOTE_TYPE, utils.format_json(fields)}

    ret = utils.subprocess({args = args})

    if ret["status"] == 0 then
        mp.osd_message("✔")
    else
        mp.osd_message("error")
    end

    player_state = nil
    start_timestamp = nil
    end_timestamp = nil
end

function init()
    ret = read_subtitles(SRT_FILE_EXTENSIONS)

    if ret == false or #subs == 0 then
        return
    end
    
    convert_into_sentences()

    mp.add_key_binding("b", "create-anki-card", create_anki_card)
    mp.add_key_binding("w", "set-start-timestamp", set_start_timestamp)
    mp.add_key_binding("e", "set-end-timestamp", set_end_timestamp)
    mp.add_key_binding("ctrl+w", "replay_anki_card", replay_anki_card)
    mp.add_key_binding("ctrl+e", "replay-the-last-seconds-of-anki-card", replay_the_last_seconds_of_anki_card)
    mp.add_key_binding("ctrl+b", "set-start-end-timestamps", set_start_end_timestamps)

    mp.register_event("playback-restart", on_playback_restart)
end

mp.register_event("file-loaded", init)
