diff --git a/README.md b/README.md index e8f23b8..05b9eb6 100644 --- a/README.md +++ b/README.md @@ -484,6 +484,7 @@ Menu { keep_open?: boolean; on_close?: string | string[]; on_search?: string | string[]; + on_paste?: string | string[]; search_style?: 'on_demand' | 'palette' | 'disabled'; // default: on_demand search_debounce?: 'submit' | number; // default: 0 search_suggestion?: string; @@ -503,6 +504,7 @@ Submenu { separator?: boolean; keep_open?: boolean; on_search?: string | string[]; + on_paste?: string | string[]; search_style?: 'on_demand' | 'palette' | 'disabled'; // default: on_demand search_debounce?: 'submit' | number; // default: 0 search_suggestion?: string; @@ -544,6 +546,8 @@ While the menu is open this value will be available in `user-data/uosc/menu/type `item.icon` property accepts icon names. You can pick one from here: [Google Material Icons](https://fonts.google.com/icons?icon.platform=web&icon.set=Material+Icons&icon.style=Rounded)\ There is also a special icon name `spinner` which will display a rotating spinner. Along with a no-op command on an item and `keep_open=true`, this can be used to display placeholder menus/items that are still loading. +`on_paste` is triggered when user pastes a string while menu is opened. Works the same as `on_search`. + When `keep_open` is `true`, activating the item will not close the menu. This property can be defined on both menus and items, and is inherited from parent to child if child doesn't overwrite it. It's usually not necessary to define `selected_index` as it'll default to the first `active` item, or 1st item in the list. diff --git a/go.mod b/go.mod index f2ed700..c21283a 100644 --- a/go.mod +++ b/go.mod @@ -6,3 +6,5 @@ require ( golang.org/x/exp v0.0.0-20231006140011-7918f672742d k8s.io/apimachinery v0.28.3 ) + +require github.com/atotto/clipboard v0.1.4 // indirect diff --git a/go.sum b/go.sum index 6bdb815..2fb21bc 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= k8s.io/apimachinery v0.28.3 h1:B1wYx8txOaCQG0HmYF6nbpU8dg6HvA06x5tEffvOe7A= diff --git a/src/uosc/elements/Menu.lua b/src/uosc/elements/Menu.lua index f373087..e51738c 100644 --- a/src/uosc/elements/Menu.lua +++ b/src/uosc/elements/Menu.lua @@ -1,13 +1,13 @@ local Element = require('elements/Element') -- Menu data structure accepted by `Menu:open(menu)`. ----@alias MenuData {id?: string; type?: string; title?: string; hint?: string; search_style?: 'on_demand' | 'palette' | 'disabled'; keep_open?: boolean; bold?: boolean; italic?: boolean; muted?: boolean; separator?: boolean; align?: 'left'|'center'|'right'; items?: MenuDataItem[]; selected_index?: integer; on_search?: string|string[]|fun(search_text: string); search_debounce?: number|string; search_submenus?: boolean; search_suggestion?: string} +---@alias MenuData {id?: string; type?: string; title?: string; hint?: string; search_style?: 'on_demand' | 'palette' | 'disabled'; keep_open?: boolean; bold?: boolean; italic?: boolean; muted?: boolean; separator?: boolean; align?: 'left'|'center'|'right'; items?: MenuDataItem[]; selected_index?: integer; on_search?: string|string[]|fun(search_text: string); on_paste?: string|string[]|fun(search_text: string); search_debounce?: number|string; search_submenus?: boolean; search_suggestion?: string} ---@alias MenuDataItem MenuDataValue|MenuData ---@alias MenuDataValue {title?: string; hint?: string; icon?: string; value: any; active?: boolean; keep_open?: boolean; selectable?: boolean; bold?: boolean; italic?: boolean; muted?: boolean; separator?: boolean; align?: 'left'|'center'|'right'} ---@alias MenuOptions {mouse_nav?: boolean; on_open?: fun(); on_close?: fun(); on_back?: fun(); on_move_item?: fun(from_index: integer, to_index: integer, submenu_path: integer[]); on_delete_item?: fun(index: integer, submenu_path: integer[])} -- Internal data structure created from `Menu`. ----@alias MenuStack {id?: string; type?: string; title?: string; hint?: string; search_style?: 'on_demand' | 'palette' | 'disabled', selected_index?: number; keep_open?: boolean; bold?: boolean; italic?: boolean; muted?: boolean; separator?: boolean; align?: 'left'|'center'|'right'; items: MenuStackItem[]; on_search?: string|string[]|fun(search_text: string); search_debounce?: number|string; search_submenus?: boolean; search_suggestion?: string; parent_menu?: MenuStack; submenu_path: integer[]; active?: boolean; width: number; height: number; top: number; scroll_y: number; scroll_height: number; title_width: number; hint_width: number; max_width: number; is_root?: boolean; fling?: Fling, search?: Search, ass_safe_title?: string} +---@alias MenuStack {id?: string; type?: string; title?: string; hint?: string; search_style?: 'on_demand' | 'palette' | 'disabled', selected_index?: number; keep_open?: boolean; bold?: boolean; italic?: boolean; muted?: boolean; separator?: boolean; align?: 'left'|'center'|'right'; items: MenuStackItem[]; on_search?: string|string[]|fun(search_text: string); on_paste?: string|string[]|fun(search_text: string); search_debounce?: number|string; search_submenus?: boolean; search_suggestion?: string; parent_menu?: MenuStack; submenu_path: integer[]; active?: boolean; width: number; height: number; top: number; scroll_y: number; scroll_height: number; title_width: number; hint_width: number; max_width: number; is_root?: boolean; fling?: Fling, search?: Search, ass_safe_title?: string} ---@alias MenuStackItem MenuStackValue|MenuStack ---@alias MenuStackValue {title?: string; hint?: string; icon?: string; value: any; active?: boolean; keep_open?: boolean; selectable?: boolean; bold?: boolean; italic?: boolean; muted?: boolean; separator?: boolean; align?: 'left'|'center'|'right'; title_width: number; hint_width: number} ---@alias Fling {y: number, distance: number, time: number, easing: fun(x: number), duration: number, update_cursor?: boolean} @@ -158,7 +158,7 @@ function Menu:update(data) local menus_to_serialize = {{new_root, data}} local old_current_id = self.current and self.current.id local menu_props_to_copy = { - 'title', 'hint', 'keep_open', 'search_style', 'search_submenus', 'search_suggestion', 'on_search', + 'title', 'hint', 'keep_open', 'search_style', 'search_submenus', 'search_suggestion', 'on_search', 'on_paste', } local item_props_to_copy = itable_join(menu_props_to_copy, { 'icon', 'active', 'bold', 'italic', 'muted', 'value', 'separator', 'selectable', 'align', @@ -716,6 +716,26 @@ function Menu:on_end() self:navigate_by_offset(math.huge) end +function Menu:paste() + local menu = self.current + local payload = get_clipboard() + if not payload then return end + if menu.search then + self:search_query_update(menu.search.query .. payload) + elseif menu.on_paste then + local paste_type = type(menu.on_paste) + if paste_type == 'string' then + mp.command(menu.on_paste .. ' ' .. payload) + elseif paste_type == 'table' then + local command = itable_join({}, menu.on_paste) + command[#command + 1] = payload + mp.command_native(command) + else + menu.on_paste(payload) + end + end +end + ---@param menu MenuStack ---@param no_select_first? boolean function Menu:search_internal(menu, no_select_first) @@ -1016,6 +1036,7 @@ function Menu:enable_key_bindings() self:add_key_binding('home', 'menu-home', self:create_key_action('on_home')) self:add_key_binding('end', 'menu-end', self:create_key_action('on_end')) self:add_key_binding('del', 'menu-delete-item', self:create_key_action('delete_selected_item')) + self:add_key_binding('ctrl+v', 'menu-paste', self:create_key_action('paste')) if self.type_to_search then self:search_enable_key_bindings() else diff --git a/src/uosc/lib/menus.lua b/src/uosc/lib/menus.lua index fa78a5e..ca99d01 100644 --- a/src/uosc/lib/menus.lua +++ b/src/uosc/lib/menus.lua @@ -29,18 +29,18 @@ function toggle_menu_with_items(opts) end end ----@param options {type: string; title: string; list_prop: string; active_prop?: string; serializer: fun(list: any, active: any): MenuDataItem[]; on_select: fun(value: any); on_move_item?: fun(from_index: integer, to_index: integer, submenu_path: integer[]); on_delete_item?: fun(index: integer, submenu_path: integer[])} -function create_self_updating_menu_opener(options) +---@param opts {type: string; title: string; list_prop: string; active_prop?: string; serializer: fun(list: any, active: any): MenuDataItem[]; on_select: fun(value: any); on_paste: fun(payload: string); on_move_item?: fun(from_index: integer, to_index: integer, submenu_path: integer[]); on_delete_item?: fun(index: integer, submenu_path: integer[])} +function create_self_updating_menu_opener(opts) return function() - if Menu:is_open(options.type) then + if Menu:is_open(opts.type) then Menu:close() return end - local list = mp.get_property_native(options.list_prop) - local active = options.active_prop and mp.get_property_native(options.active_prop) or nil + local list = mp.get_property_native(opts.list_prop) + local active = opts.active_prop and mp.get_property_native(opts.active_prop) or nil local menu - local function update() menu:update_items(options.serializer(list, active)) end + local function update() menu:update_items(opts.serializer(list, active)) end local ignore_initial_list = true local function handle_list_prop_change(name, value) @@ -62,25 +62,31 @@ function create_self_updating_menu_opener(options) end end - local initial_items, selected_index = options.serializer(list, active) + local initial_items, selected_index = opts.serializer(list, active) -- Items and active_index are set in the handle_prop_change callback, since adding -- a property observer triggers its handler immediately, we just let that initialize the items. menu = Menu:open( - {type = options.type, title = options.title, items = initial_items, selected_index = selected_index}, - options.on_select, { + { + type = opts.type, + title = opts.title, + items = initial_items, + selected_index = selected_index, + on_paste = opts.on_paste, + }, + opts.on_select, { on_open = function() - mp.observe_property(options.list_prop, 'native', handle_list_prop_change) - if options.active_prop then - mp.observe_property(options.active_prop, 'native', handle_active_prop_change) + mp.observe_property(opts.list_prop, 'native', handle_list_prop_change) + if opts.active_prop then + mp.observe_property(opts.active_prop, 'native', handle_active_prop_change) end end, on_close = function() mp.unobserve_property(handle_list_prop_change) mp.unobserve_property(handle_active_prop_change) end, - on_move_item = options.on_move_item, - on_delete_item = options.on_delete_item, + on_move_item = opts.on_move_item, + on_delete_item = opts.on_delete_item, }) end end @@ -155,7 +161,7 @@ function create_select_tracklist_type_menu_opener(menu_title, track_type, track_ return items, active_index or first_item_index end - local function selection_handler(value) + local function handle_select(value) if value == '{download}' then mp.command(download_command) elseif value == '{load}' then @@ -170,12 +176,21 @@ function create_select_tracklist_type_menu_opener(menu_title, track_type, track_ end end + local function handle_paste(value) + mp.commandv(track_type .. '-add', value) + -- If subtitle track was loaded, assume the user also wants to see it + if track_type == 'sub' then + mp.commandv('set', 'sub-visibility', 'yes') + end + end + return create_self_updating_menu_opener({ title = menu_title, type = track_type, list_prop = 'track-list', serializer = serialize_tracklist, - on_select = selection_handler, + on_select = handle_select, + on_paste = handle_paste, }) end diff --git a/src/uosc/lib/utils.lua b/src/uosc/lib/utils.lua index 2945849..94c7158 100644 --- a/src/uosc/lib/utils.lua +++ b/src/uosc/lib/utils.lua @@ -728,6 +728,32 @@ function find_active_keybindings(key) return not key and active or active[key] end +---@return string|nil +function get_clipboard() + local result = mp.command_native({ + name = 'subprocess', + capture_stderr = true, + capture_stdout = true, + playback_only = false, + args = {config.ziggy_path, 'get-clipboard'}, + }) + + local function print_error(message) + msg.error('Getting clipboard data failed. Error: ' .. message) + end + + if result.status == 0 then + local data = utils.parse_json(result.stdout) + if data and data.payload then + return data.payload + else + print_error(data and (data.error and data.message or 'unknown error') or 'couldn\'t parse json') + end + else + print_error('exit code ' .. result.status .. ': ' .. result.stdout .. result.stderr) + end +end + --[[ RENDERING ]] function render() diff --git a/src/ziggy/commands/clipboard.go b/src/ziggy/commands/clipboard.go new file mode 100644 index 0000000..0c661ce --- /dev/null +++ b/src/ziggy/commands/clipboard.go @@ -0,0 +1,39 @@ +package commands + +import ( + "flag" + "fmt" + "uosc/bins/src/ziggy/lib" + + "github.com/atotto/clipboard" +) + +type ClipboardResult struct { + Payload string `json:"payload"` +} + +func GetClipboard(_ []string) { + fmt.Print(string(lib.Must(lib.JSONMarshal(ClipboardResult{ + Payload: lib.Must(clipboard.ReadAll()), + })))) +} + +func SetClipboard(args []string) { + cmd := flag.NewFlagSet("set-clipboard", flag.ExitOnError) + + lib.Check(cmd.Parse(args)) + + values := cmd.Args() + value := "" + if len(values) > 0 { + value = values[0] + } + + lib.Check(cmd.Parse(args)) + + lib.Check(clipboard.WriteAll(value)) + + fmt.Print(string(lib.Must(lib.JSONMarshal(ClipboardResult{ + Payload: value, + })))) +} diff --git a/src/ziggy/commands/subtitles.go b/src/ziggy/commands/subtitles.go new file mode 100644 index 0000000..9f24aeb --- /dev/null +++ b/src/ziggy/commands/subtitles.go @@ -0,0 +1,175 @@ +package commands + +import ( + "bytes" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "regexp" + "slices" + "strings" + + "uosc/bins/src/ziggy/lib" +) + +const OPEN_SUBTITLES_API_URL = "https://api.opensubtitles.com/api/v1" + +type DownloadRequestData struct { + FileId int `json:"file_id"` +} + +type DownloadResponseData struct { + Link string `json:"link"` + FileName string `json:"file_name"` + Requests int `json:"requests"` + Remaining int `json:"remaining"` + Message string `json:"message"` + ResetTime string `json:"reset_time"` + ResetTimeUTC string `json:"reset_time_utc"` +} + +type DownloadData struct { + File string `json:"file"` + Remaining int `json:"remaining"` + Total int `json:"total"` + ResetTime string `json:"reset_time"` +} + +func SearchSubtitles(args []string) { + cmd := flag.NewFlagSet("search-subtitles", flag.ExitOnError) + argApiKey := cmd.String("api-key", "", "Open Subtitles consumer API key.") + argAgent := cmd.String("agent", "", "User-Agent header. Format: appname v1.0") + argLanguages := cmd.String("languages", "", "What languages to search for.") + argHash := cmd.String("hash", "", "What file to hash and add to search query.") + argQuery := cmd.String("query", "", "String query to use.") + argPage := cmd.Int("page", 1, "Results page, starting at 1.") + + lib.Check(cmd.Parse(args)) + + // Validation + if len(*argApiKey) == 0 { + lib.Check(errors.New("--api-key is required")) + } + if len(*argAgent) == 0 { + lib.Check(errors.New("--agent is required")) + } + if len(*argHash) == 0 && len(*argQuery) == 0 { + lib.Check(errors.New("at least one of --query or --hash is required")) + } + if len(*argLanguages) == 0 { + lib.Check(errors.New("--languages is required")) + } + + // "Send request parameters sorted, and send all queries in lowercase." + params := []string{} + languageDelimiterRE := regexp.MustCompile(" *, *") + languages := languageDelimiterRE.Split(*argLanguages, -1) + slices.Sort(languages) + params = append(params, "languages="+escapeParam(strings.Join(languages, ","))) + if len(*argHash) > 0 { + hash, err := lib.OSDBHashFile(*argHash) + if err == nil { + params = append(params, "moviehash="+escapeParam(hash)) + } else if len(*argQuery) == 0 { + lib.Check(fmt.Errorf("couldn't hash the file (%w) and query is empty", err)) + } + } + params = append(params, "page="+escapeParam(fmt.Sprint(*argPage))) + if len(*argQuery) > 0 { + params = append(params, "query="+escapeParam(*argQuery)) + } + + client := http.Client{} + req := lib.Must(http.NewRequest("GET", OPEN_SUBTITLES_API_URL+"/subtitles?"+strings.Join(params, "&"), nil)) + req.Header = http.Header{ + "Api-Key": {*argApiKey}, + "User-Agent": {*argAgent}, + } + + resp := lib.Must(client.Do(req)) + defer resp.Body.Close() + + if resp.StatusCode == http.StatusOK { + fmt.Print(string(lib.Must(io.ReadAll(resp.Body)))) + } else { + lib.Check(errors.New(resp.Status)) + } +} + +func DownloadSubtitles(args []string) { + cmd := flag.NewFlagSet("download-subtitles", flag.ExitOnError) + argApiKey := cmd.String("api-key", "", "Open Subtitles consumer API key.") + argAgent := cmd.String("agent", "", "User-Agent header. Format: appname v1.0") + argFileID := cmd.Int("file-id", 0, "Subtitle file ID to download.") + argDestination := cmd.String("destination", "", "Destination directory.") + + lib.Check(cmd.Parse(args)) + + // Validation + if len(*argApiKey) == 0 { + lib.Check(errors.New("--api-key is required")) + } + if len(*argAgent) == 0 { + lib.Check(errors.New("--agent is required")) + } + if *argFileID == 0 { + lib.Check(errors.New("--file-id is required")) + } + if len(*argDestination) == 0 { + lib.Check(errors.New("--destination is required")) + } + + // Create the directory if it doesn't exist + if _, err := os.Stat(*argDestination); os.IsNotExist(err) { + os.MkdirAll(*argDestination, 0755) + } + + data := bytes.NewBuffer(lib.Must(lib.JSONMarshal(DownloadRequestData{FileId: *argFileID}))) + client := http.Client{} + req := lib.Must(http.NewRequest("POST", OPEN_SUBTITLES_API_URL+"/download", data)) + req.Header = http.Header{ + "Accept": {"application/json"}, + "Api-Key": {*argApiKey}, + "Content-Type": {"application/json"}, + "User-Agent": {*argAgent}, + } + + resp := lib.Must(client.Do(req)) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + lib.Check(errors.New(resp.Status)) + } + var downloadData DownloadResponseData + lib.Check(json.Unmarshal(lib.Must(io.ReadAll(resp.Body)), &downloadData)) + filePath := filepath.Join(*argDestination, downloadData.FileName) + outFile := lib.Must(os.Create(filePath)) + defer outFile.Close() + + response := lib.Must(http.Get(downloadData.Link)) + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + lib.Check(fmt.Errorf("downloading failed: %s", response.Status)) + } + + lib.Must(io.Copy(outFile, response.Body)) + + fmt.Print(string(lib.Must(lib.JSONMarshal(DownloadData{ + File: filePath, + Remaining: downloadData.Remaining, + Total: downloadData.Remaining + downloadData.Requests, + ResetTime: downloadData.ResetTime, + })))) +} + +// Escape and lowercase (open subtitles requirement) a URL parameter +func escapeParam(str string) string { + return url.QueryEscape(strings.ToLower(str)) +} diff --git a/src/ziggy/lib/utils.go b/src/ziggy/lib/utils.go new file mode 100644 index 0000000..dc661f4 --- /dev/null +++ b/src/ziggy/lib/utils.go @@ -0,0 +1,98 @@ +package lib + +import ( + "bytes" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "os" +) + +type ErrorData struct { + Error bool `json:"error"` + Message string `json:"message"` +} + +func Check(err error) { + if err != nil { + res := ErrorData{Error: true, Message: err.Error()} + json, err := json.Marshal(res) + if err != nil { + panic(err) + } + fmt.Print(string(json)) + os.Exit(0) + } +} + +func Must[T any](t T, err error) T { + Check(err) + return t +} + +const OSDBChunkSize = 65536 // 64k + +// Generate an OSDB hash for a file. +func OSDBHashFile(filePath string) (hash string, err error) { + file, err := os.Open(filePath) + if err != nil { + return "", errors.New("couldn't open file for hashing") + } + + fi, err := file.Stat() + if err != nil { + return "", errors.New("couldn't stat file for hashing") + } + if fi.Size() < OSDBChunkSize { + return "", errors.New("file is too small to generate a valid OSDB hash") + } + + // Read head and tail blocks + buf := make([]byte, OSDBChunkSize*2) + err = readChunk(file, 0, buf[:OSDBChunkSize]) + if err != nil { + return + } + err = readChunk(file, fi.Size()-OSDBChunkSize, buf[OSDBChunkSize:]) + if err != nil { + return + } + + // Convert to uint64, and sum + var nums [(OSDBChunkSize * 2) / 8]uint64 + reader := bytes.NewReader(buf) + err = binary.Read(reader, binary.LittleEndian, &nums) + if err != nil { + return "", err + } + var hashUint uint64 + for _, num := range nums { + hashUint += num + } + + hashUint = hashUint + uint64(fi.Size()) + + return fmt.Sprintf("%016x", hashUint), nil +} + +// Read a chunk of a file at `offset` so as to fill `buf`. +func readChunk(file *os.File, offset int64, buf []byte) (err error) { + n, err := file.ReadAt(buf, offset) + if err != nil { + return err + } + if n != OSDBChunkSize { + return fmt.Errorf("invalid read %v", n) + } + return +} + +// Because the default `json.Marshal` HTML escapes `&,<,>` characters and it can't be turned off... +func JSONMarshal(t interface{}) ([]byte, error) { + buffer := &bytes.Buffer{} + encoder := json.NewEncoder(buffer) + encoder.SetEscapeHTML(false) + err := encoder.Encode(t) + return buffer.Bytes(), err +} diff --git a/src/ziggy/ziggy.go b/src/ziggy/ziggy.go index 95ef592..c1ed502 100644 --- a/src/ziggy/ziggy.go +++ b/src/ziggy/ziggy.go @@ -1,262 +1,33 @@ package main import ( - "bytes" - "encoding/binary" - "encoding/json" "errors" - "flag" - "fmt" - "io" - "net/http" - "net/url" "os" - "path/filepath" - "regexp" - "slices" - "strings" + "uosc/bins/src/ziggy/commands" ) -const OPEN_SUBTITLES_API_URL = "https://api.opensubtitles.com/api/v1" -const OSDBChunkSize = 65536 // 64k - func main() { - srSubsCmd := flag.NewFlagSet("search-subtitles", flag.ExitOnError) - srSubsApiKey := srSubsCmd.String("api-key", "", "Open Subtitles consumer API key.") - srSubsAgent := srSubsCmd.String("agent", "", "User-Agent header. Format: appname v1.0") - srSubsLanguages := srSubsCmd.String("languages", "", "What languages to search for.") - srSubsHash := srSubsCmd.String("hash", "", "What file to hash and add to search query.") - srSubsQuery := srSubsCmd.String("query", "", "String query to use.") - srSubsPage := srSubsCmd.Int("page", 1, "Results page, starting at 1.") + command := "help" + args := os.Args[2:] - dlSubsCmd := flag.NewFlagSet("download-subtitles", flag.ExitOnError) - dlSubsApiKey := dlSubsCmd.String("api-key", "", "Open Subtitles consumer API key.") - dlSubsAgent := dlSubsCmd.String("agent", "", "User-Agent header. Format: appname v1.0") - dlSubsID := dlSubsCmd.Int("file-id", 0, "Subtitle file ID to download.") - dlSubsDestination := dlSubsCmd.String("destination", "", "Destination directory.") - - if len(os.Args) <= 1 { - panic(errors.New("command required")) + if len(os.Args) > 1 { + command = os.Args[1] } - switch os.Args[1] { + switch command { case "search-subtitles": - check(srSubsCmd.Parse(os.Args[2:])) - - // Validation - if len(*srSubsApiKey) == 0 { - check(errors.New("--api-key is required")) - } - if len(*srSubsAgent) == 0 { - check(errors.New("--agent is required")) - } - if len(*srSubsHash) == 0 && len(*srSubsQuery) == 0 { - check(errors.New("at least one of --query or --hash is required")) - } - if len(*srSubsLanguages) == 0 { - check(errors.New("--languages is required")) - } - - // "Send request parameters sorted, and send all queries in lowercase." - params := []string{} - languageDelimiterRE := regexp.MustCompile(" *, *") - languages := languageDelimiterRE.Split(*srSubsLanguages, -1) - slices.Sort(languages) - params = append(params, "languages="+escape(strings.Join(languages, ","))) - if len(*srSubsHash) > 0 { - params = append(params, "moviehash="+escape(must(hashFile(*srSubsHash)))) - } - params = append(params, "page="+escape(fmt.Sprint(*srSubsPage))) - if len(*srSubsQuery) > 0 { - params = append(params, "query="+escape(*srSubsQuery)) - } - - client := http.Client{} - req := must(http.NewRequest("GET", OPEN_SUBTITLES_API_URL+"/subtitles?"+strings.Join(params, "&"), nil)) - req.Header = http.Header{ - "Api-Key": {*srSubsApiKey}, - "User-Agent": {*srSubsAgent}, - } - - resp := must(client.Do(req)) - defer resp.Body.Close() - - if resp.StatusCode == http.StatusOK { - fmt.Print(string(must(io.ReadAll(resp.Body)))) - } else { - check(errors.New(resp.Status)) - } + commands.SearchSubtitles(args) case "download-subtitles": - check(dlSubsCmd.Parse(os.Args[2:])) + commands.DownloadSubtitles(args) - // Validation - if len(*dlSubsApiKey) == 0 { - check(errors.New("--api-key is required")) - } - if len(*dlSubsAgent) == 0 { - check(errors.New("--agent is required")) - } - if *dlSubsID == 0 { - check(errors.New("--file-id is required")) - } - if len(*dlSubsDestination) == 0 { - check(errors.New("--destination is required")) - } + case "get-clipboard": + commands.GetClipboard(args) - // Create the directory if it doesn't exist - if _, err := os.Stat(*dlSubsDestination); os.IsNotExist(err) { - os.MkdirAll(*dlSubsDestination, 0755) - } + case "set-clipboard": + commands.SetClipboard(args) - data := bytes.NewBuffer(must(JSONMarshal(DownloadRequestData{FileId: *dlSubsID}))) - client := http.Client{} - req := must(http.NewRequest("POST", OPEN_SUBTITLES_API_URL+"/download", data)) - req.Header = http.Header{ - "Accept": {"application/json"}, - "Api-Key": {*dlSubsApiKey}, - "Content-Type": {"application/json"}, - "User-Agent": {*dlSubsAgent}, - } - - resp := must(client.Do(req)) - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - check(errors.New(resp.Status)) - } - var downloadData DownloadResponseData - check(json.Unmarshal(must(io.ReadAll(resp.Body)), &downloadData)) - filePath := filepath.Join(*dlSubsDestination, downloadData.FileName) - outFile := must(os.Create(filePath)) - defer outFile.Close() - - response := must(http.Get(downloadData.Link)) - defer response.Body.Close() - - if response.StatusCode != http.StatusOK { - check(fmt.Errorf("downloading failed: %s", response.Status)) - } - - must(io.Copy(outFile, response.Body)) - - fmt.Print(string(must(JSONMarshal(DownloadData{ - File: filePath, - Remaining: downloadData.Remaining, - Total: downloadData.Remaining + downloadData.Requests, - ResetTime: downloadData.ResetTime, - })))) + default: + panic(errors.New("command required")) } } - -type DownloadRequestData struct { - FileId int `json:"file_id"` -} - -type DownloadResponseData struct { - Link string `json:"link"` - FileName string `json:"file_name"` - Requests int `json:"requests"` - Remaining int `json:"remaining"` - Message string `json:"message"` - ResetTime string `json:"reset_time"` - ResetTimeUTC string `json:"reset_time_utc"` -} - -type DownloadData struct { - File string `json:"file"` - Remaining int `json:"remaining"` - Total int `json:"total"` - ResetTime string `json:"reset_time"` -} - -type ErrorData struct { - Error bool `json:"error"` - Message string `json:"message"` -} - -func check(err error) { - if err != nil { - res := ErrorData{Error: true, Message: err.Error()} - json, err := json.Marshal(res) - if err != nil { - panic(err) - } - fmt.Print(string(json)) - os.Exit(0) - } -} - -func must[T any](t T, err error) T { - check(err) - return t -} - -// Escape and lowercase (open subtitles requirement) a URL parameter -func escape(str string) string { - return url.QueryEscape(strings.ToLower(str)) -} - -// Generate an OSDB hash for a file -func hashFile(filePath string) (hash string, err error) { - file, err := os.Open(filePath) - if err != nil { - return "", errors.New("couldn't open file for hashing") - } - - fi, err := file.Stat() - if err != nil { - return "", errors.New("couldn't stat file for hashing") - } - if fi.Size() < OSDBChunkSize { - return "", errors.New("file is too small to generate a valid OSDB hash") - } - - // Read head and tail blocks - buf := make([]byte, OSDBChunkSize*2) - err = readChunk(file, 0, buf[:OSDBChunkSize]) - if err != nil { - return - } - err = readChunk(file, fi.Size()-OSDBChunkSize, buf[OSDBChunkSize:]) - if err != nil { - return - } - - // Convert to uint64, and sum - var nums [(OSDBChunkSize * 2) / 8]uint64 - reader := bytes.NewReader(buf) - err = binary.Read(reader, binary.LittleEndian, &nums) - if err != nil { - return "", err - } - var hashUint uint64 - for _, num := range nums { - hashUint += num - } - - hashUint = hashUint + uint64(fi.Size()) - - return fmt.Sprintf("%016x", hashUint), nil -} - -// Read a chunk of a file at `offset` so as to fill `buf` -func readChunk(file *os.File, offset int64, buf []byte) (err error) { - n, err := file.ReadAt(buf, offset) - if err != nil { - return err - } - if n != OSDBChunkSize { - return fmt.Errorf("invalid read %v", n) - } - return -} - -// Because the default `json.Marshal` HTML escapes `&,<,>` characters and it can't be turned off... -func JSONMarshal(t interface{}) ([]byte, error) { - buffer := &bytes.Buffer{} - encoder := json.NewEncoder(buffer) - encoder.SetEscapeHTML(false) - err := encoder.Encode(t) - return buffer.Bytes(), err -}