summaryrefslogtreecommitdiff
path: root/mac/.config/mpv/scripts/youtube-search.lua
diff options
context:
space:
mode:
Diffstat (limited to 'mac/.config/mpv/scripts/youtube-search.lua')
-rw-r--r--mac/.config/mpv/scripts/youtube-search.lua419
1 files changed, 419 insertions, 0 deletions
diff --git a/mac/.config/mpv/scripts/youtube-search.lua b/mac/.config/mpv/scripts/youtube-search.lua
new file mode 100644
index 0000000..8989447
--- /dev/null
+++ b/mac/.config/mpv/scripts/youtube-search.lua
@@ -0,0 +1,419 @@
+--[[
+ This script allows users to search and open youtube results from within mpv.
+ Available at: https://github.com/CogentRedTester/mpv-scripts
+
+ Users can open the search page with Y, and use Y again to open a search.
+ Alternatively, Ctrl+y can be used at any time to open a search.
+ Esc can be used to close the page.
+ Enter will open the selected item, Shift+Enter will append the item to the playlist.
+
+ This script requires that my other scripts `scroll-list` and `user-input` be installed.
+ scroll-list.lua and user-input-module.lua must be in the ~~/script-modules/ directory,
+ while user-input.lua should be loaded by mpv normally.
+
+ https://github.com/CogentRedTester/mpv-scroll-list
+ https://github.com/CogentRedTester/mpv-user-input
+
+ This script also requires a youtube API key to be entered.
+ The API key must be passed to the `API_key` script-opt.
+ A personal API key is free and can be created from:
+ https://console.developers.google.com/apis/api/youtube.googleapis.com/
+
+ The script also requires that curl be in the system path.
+
+ An alternative to using the official youtube API is to use Invidious.
+ This script has experimental support for Invidious searches using the 'invidious',
+ 'API_path', and 'frontend' options. API_path refers to the url of the API the
+ script uses, Invidious API paths are usually in the form:
+ https://domain.name/api/v1/
+ The frontend option is the url to actualy try to load videos from. This
+ can probably be the same as the above url:
+ https://domain.name
+ Since the url syntax seems to be identical between Youtube and Invidious,
+ it should be possible to mix these options, a.k.a. using the Google
+ API to get videos from an Invidious frontend, or to use an Invidious
+ API to get videos from Youtube.
+ The 'invidious' option tells the script that the API_path is for an
+ Invidious path. This is to support other possible API options in the future.
+]]
+--
+
+local mp = require("mp")
+local msg = require("mp.msg")
+local utils = require("mp.utils")
+local opts = require("mp.options")
+
+package.path = mp.command_native({ "expand-path", "~~/script-modules/?.lua;" }) .. package.path
+local ui = require("user-input-module")
+local list = require("scroll-list")
+
+local o = {
+ API_key = io.popen("pass show api/google-cloud/youtube-search"):read("*a"):gsub("%s+", ""),
+
+ --number of search results to show in the list
+ num_results = 40,
+
+ --the url to send API calls to
+ API_path = "https://www.googleapis.com/youtube/v3/",
+
+ --attempt this API if the default fails
+ fallback_API_path = "",
+
+ --the url to load videos from
+ frontend = "https://www.youtube.com",
+
+ --use invidious API calls
+ invidious = false,
+
+ --whether the fallback uses invidious as well
+ fallback_invidious = false,
+}
+
+opts.read_options(o)
+
+--ensure the URL options are properly formatted
+local function format_options()
+ if o.API_path:sub(-1) ~= "/" then
+ o.API_path = o.API_path .. "/"
+ end
+ if o.fallback_API_path:sub(-1) ~= "/" then
+ o.fallback_API_path = o.fallback_API_path .. "/"
+ end
+ if o.frontend:sub(-1) == "/" then
+ o.frontend = o.frontend:sub(1, -2)
+ end
+end
+
+format_options()
+
+list.header = ("%s Search: \\N-------------------------------------------------"):format(
+ o.invidious and "Invidious" or "Youtube"
+)
+list.num_entries = 17
+list.list_style = [[{\fs10}\N{\q2\fs25\c&Hffffff&}]]
+list.empty_text = "enter search query"
+
+local ass_escape = list.ass_escape
+
+--encodes a string so that it uses url percent encoding
+--this function is based on code taken from here: https://rosettacode.org/wiki/URL_encoding#Lua
+local function encode_string(str)
+ if type(str) ~= "string" then
+ return str
+ end
+ local output, t = str:gsub("[^%w]", function(char)
+ return string.format("%%%X", string.byte(char))
+ end)
+ return output
+end
+
+--convert HTML character codes to the correct characters
+local function html_decode(str)
+ if type(str) ~= "string" then
+ return str
+ end
+
+ return str:gsub("&(#?)(%w-);", function(is_ascii, code)
+ if is_ascii == "#" then
+ return string.char(tonumber(code))
+ end
+ if code == "amp" then
+ return "&"
+ end
+ if code == "quot" then
+ return '"'
+ end
+ if code == "apos" then
+ return "'"
+ end
+ if code == "lt" then
+ return "<"
+ end
+ if code == "gt" then
+ return ">"
+ end
+ return nil
+ end)
+end
+
+--creates a formatted results table from an invidious API call
+local function format_invidious_results(response)
+ if not response then
+ return nil
+ end
+ local results = {}
+
+ for i, item in ipairs(response) do
+ if i > o.num_results then
+ break
+ end
+
+ local t = {}
+ table.insert(results, t)
+
+ t.title = html_decode(item.title)
+ t.channelTitle = html_decode(item.author)
+ if item.type == "video" then
+ t.type = "video"
+ t.id = item.videoId
+ elseif item.type == "playlist" then
+ t.type = "playlist"
+ t.id = item.playlistId
+ elseif item.type == "channel" then
+ t.type = "channel"
+ t.id = item.authorId
+ t.title = t.channelTitle
+ end
+ end
+
+ return results
+end
+
+--creates a formatted results table from a youtube API call
+function format_youtube_results(response)
+ if not response or not response.items then
+ return nil
+ end
+ local results = {}
+
+ for _, item in ipairs(response.items) do
+ local t = {}
+ table.insert(results, t)
+
+ t.title = html_decode(item.snippet.title)
+ t.channelTitle = html_decode(item.snippet.channelTitle)
+
+ if item.id.kind == "youtube#video" then
+ t.type = "video"
+ t.id = item.id.videoId
+ elseif item.id.kind == "youtube#playlist" then
+ t.type = "playlist"
+ t.id = item.id.playlistId
+ elseif item.id.kind == "youtube#channel" then
+ t.type = "channel"
+ t.id = item.id.channelId
+ end
+ end
+
+ return results
+end
+
+--sends an API request
+local function send_request(type, queries, API_path)
+ local url = (API_path or o.API_path) .. type
+ url = url .. "?"
+
+ for key, value in pairs(queries) do
+ msg.verbose(key, value)
+ url = url .. "&" .. key .. "=" .. encode_string(value)
+ end
+
+ msg.debug(url)
+ local request = mp.command_native({
+ name = "subprocess",
+ capture_stdout = true,
+ capture_stderr = true,
+ playback_only = false,
+ args = { "curl", url },
+ })
+
+ local response = utils.parse_json(request.stdout)
+ msg.trace(utils.to_string(request))
+
+ if request.status ~= 0 then
+ msg.error(request.stderr)
+ return nil
+ end
+ if not response then
+ msg.error("Could not parse response:")
+ msg.error(request.stdout)
+ return nil
+ end
+ if response.error then
+ msg.error(request.stdout)
+ return nil
+ end
+
+ return response
+end
+
+--sends a search API request - handles Google/Invidious API differences
+local function search_request(queries, API_path, invidious)
+ list.header = ("%s Search: %s\\N-------------------------------------------------"):format(
+ invidious and "Invidious" or "Youtube",
+ ass_escape(queries.q, true)
+ )
+ list.list = {}
+ list.empty_text = "~"
+ list:update()
+ local results = {}
+
+ --we need to modify the returned results so that the rest of the script can read it
+ if invidious then
+ --Invidious searches are done with pages rather than a max result number
+ local page = 1
+ while #results < o.num_results do
+ queries.page = page
+
+ local response = send_request("search", queries, API_path)
+ response = format_invidious_results(response)
+ if not response then
+ msg.warn("Search did not return a results list")
+ return
+ end
+ if #response == 0 then
+ break
+ end
+
+ for _, item in ipairs(response) do
+ table.insert(results, item)
+ end
+
+ page = page + 1
+ end
+ else
+ local response = send_request("search", queries, API_path)
+ results = format_youtube_results(response)
+ end
+
+ --print error messages to console if the API request fails
+ if not results then
+ msg.warn("Search did not return a results list")
+ return
+ end
+
+ list.empty_text = "no results"
+ return results
+end
+
+local function insert_video(item)
+ list:insert({
+ ass = ("%s {\\c&aaaaaa&}%s"):format(ass_escape(item.title), ass_escape(item.channelTitle)),
+ url = ("%s/watch?v=%s"):format(o.frontend, item.id),
+ })
+end
+
+local function insert_playlist(item)
+ list:insert({
+ ass = ("🖿 %s {\\c&aaaaaa&}%s"):format(ass_escape(item.title), ass_escape(item.channelTitle)),
+ url = ("%s/playlist?list=%s"):format(o.frontend, item.id),
+ })
+end
+
+local function insert_channel(item)
+ list:insert({
+ ass = ("👤 %s"):format(ass_escape(item.title)),
+ url = ("%s/channel/%s"):format(o.frontend, item.id),
+ })
+end
+
+local function reset_list()
+ list.selected = 1
+ list:clear()
+end
+
+--creates the search request queries depending on what API we're using
+local function get_search_queries(query, invidious)
+ if invidious then
+ return {
+ q = query,
+ type = "all",
+ page = 1,
+ }
+ else
+ return {
+ key = o.API_key,
+ q = query,
+ part = "id,snippet",
+ maxResults = o.num_results,
+ }
+ end
+end
+
+local function search(query)
+ local response = search_request(get_search_queries(query, o.invidious), o.API_path, o.invidious)
+ if not response and o.fallback_API_path ~= "/" then
+ msg.info("search failed - attempting fallback")
+ response =
+ search_request(get_search_queries(query, o.fallback_invidious), o.fallback_API_path, o.fallback_invidious)
+ end
+
+ if not response then
+ return
+ end
+ reset_list()
+
+ for _, item in ipairs(response) do
+ if item.type == "video" then
+ insert_video(item)
+ elseif item.type == "playlist" then
+ insert_playlist(item)
+ elseif item.type == "channel" then
+ insert_channel(item)
+ end
+ end
+ list:update()
+ list:open()
+end
+
+local function play_result(flag)
+ if not list[list.selected] then
+ return
+ end
+ if flag == "new_window" then
+ mp.commandv("run", "mpv", list[list.selected].url)
+ return
+ end
+
+ mp.commandv("loadfile", list[list.selected].url, flag)
+ if flag == "replace" then
+ list:close()
+ end
+end
+
+table.insert(list.keybinds, {
+ "ENTER",
+ "play",
+ function()
+ play_result("replace")
+ end,
+ {},
+})
+table.insert(list.keybinds, {
+ "Shift+ENTER",
+ "play_append",
+ function()
+ play_result("append-play")
+ end,
+ {},
+})
+table.insert(list.keybinds, {
+ "Ctrl+ENTER",
+ "play_new_window",
+ function()
+ play_result("new_window")
+ end,
+ {},
+})
+
+local function open_search_input()
+ ui.get_user_input(function(input)
+ if not input then
+ return
+ end
+ search(input)
+ end, { request_text = "Enter Query:" })
+end
+
+mp.add_key_binding("", "yt", open_search_input)
+
+mp.add_key_binding("", "youtube-search", function()
+ if not list.hidden then
+ open_search_input()
+ else
+ list:open()
+ if #list.list == 0 then
+ open_search_input()
+ end
+ end
+end)