feat: support for paste in menus

This adds several paste related features:

- You can paste into search input box.
- You can paste a url or a path into track select menus (subtitles/audio/video) to load it.
- Menu API now accepts `on_paste` option, which works the same as `on_search`.

closes #765, ref #497
This commit is contained in:
tomasklaen
2023-11-04 12:22:23 +01:00
parent 4cdd6c585d
commit 81f402ad73
10 changed files with 415 additions and 262 deletions

View File

@@ -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.

2
go.mod
View File

@@ -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

2
go.sum
View File

@@ -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=

View File

@@ -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

View File

@@ -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

View File

@@ -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()

View File

@@ -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,
}))))
}

View File

@@ -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))
}

98
src/ziggy/lib/utils.go Normal file
View File

@@ -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
}

View File

@@ -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
}