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:
@@ -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
2
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
|
||||
|
2
go.sum
2
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=
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
||||
|
@@ -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()
|
||||
|
39
src/ziggy/commands/clipboard.go
Normal file
39
src/ziggy/commands/clipboard.go
Normal 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,
|
||||
}))))
|
||||
}
|
175
src/ziggy/commands/subtitles.go
Normal file
175
src/ziggy/commands/subtitles.go
Normal 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
98
src/ziggy/lib/utils.go
Normal 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
|
||||
}
|
@@ -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
|
||||
}
|
||||
|
Reference in New Issue
Block a user