diff --git a/README.md b/README.md index 36d0b44..82bd1d8 100644 --- a/README.md +++ b/README.md @@ -162,7 +162,5 @@ This script allows users to set custom variables which can be used in commands a ## youtube-search -A script that allows users to search and open youtube results from within mpv. -Requires [scroll-list](#scroll-list), [user-input](#user-input), curl, and a youtube API key. -Alternatively, an Invidious frontend can be used for searches without requiring an API key. -See the file header for details. +A script that allows users to search and open youtube results from within mpv with the `Y` key. +Requires `yt-dlp`. diff --git a/youtube-search.lua b/youtube-search.lua index 00eb18a..93dbc14 100644 --- a/youtube-search.lua +++ b/youtube-search.lua @@ -1,345 +1,292 @@ --[[ - This script allows users to search and open youtube results from within mpv. + This script allows users to search and open youtube results from within mpv using yt-dlp. Available at: https://github.com/CogentRedTester/mpv-scripts - Users can open the search page with Y, and use Y again to open a search. + The Y button opens the latest page of search results (or prompts for input + if nothing has yet been searched). + The search page has an entry to do a new 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. + + yt-dlp must also be available in the system path ]]-- 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 input = require 'mp.input' local o = { - API_key = "", - - --number of search results to show in the list + --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/", + --The url to send API calls to. + yt_dlp_path = "yt-dlp", - --attempt this API if the default fails - fallback_API_path = "", + --The search query to sent to yt-dlp. `%s` is substituted for the search query. + search_query = "https://www.youtube.com/search?q=%s", - --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 + --Save search history between mpv sessions. + save_search_history = true, } 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" +---@class SearchResult +---@field id string +---@field type 'video'|'playlist'|'channel' +---@field title string +---@field channelTitle string +---@field url string + +---@class LatestSearch +---@field latest_results SearchResult[] +---@field query string +---@field display_list string[] -local ass_escape = list.ass_escape +---@type LatestSearch|nil +local latest_search = nil ---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 selection_open = false + +---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 +---@param str string +---@return string 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 -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 +---@param str string +---@return table[]|nil +local function json_parse_iterate(str) + local t = {} - return results -end + local json, err, trail = utils.parse_json(str, true) + if not json then return nil 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 + repeat + table.insert(t, json) + json, err, trail = utils.parse_json(trail, true) + until not json - return results + return t 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", +---@param query string +---@return table[]|nil +local function search_ytdlp(query) + local req = mp.command_native({ + name = 'subprocess', + playback_only = false, capture_stdout = true, capture_stderr = true, - playback_only = false, - args = {"curl", url} + args = {o.yt_dlp_path, '-s', ('-I1:%d'):format(o.num_results), '--flat-playlist', '-j', o.search_query:format(encode_string(query))} }) - local response = utils.parse_json(request.stdout) - msg.trace(utils.to_string(request)) + msg.trace(utils.to_string(req)) + local results = json_parse_iterate(req.stdout) - if request.status ~= 0 then - msg.error(request.stderr) + if req.status ~= 0 then + msg.error(req.stderr) return nil end - if not response then + if not results or #results == 0 then msg.error("Could not parse response:") - msg.error(request.stdout) - return nil - end - if response.error then - msg.error(request.stdout) + msg.error(req.stdout) return nil end - return response + return results 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 +---@param results table|nil +---@return SearchResult[]|nil +local function process_ytdlp_results(results) + if not results then return nil end + + ---@type SearchResult[] + local t = {} + + for _, v in ipairs(results) do + local url_type = string.match(v.url, '^https://www.youtube.com/([^/?]+)') + + ---@type SearchResult + local result = { + id = v.id, + type = url_type == 'watch' and 'video' or url_type, + title = v.title or '', + channelTitle = v.channel or '', + url = url_type == 'watch' and ("%s/watch?v=%s"):format(o.frontend, v.id) + or url_type == 'playlist' and ("%s/playlist?list=%s"):format(o.frontend, v.id) + or url_type == 'channel' and ("%s/channel/%s"):format(o.frontend, v.id) + or '' + } - --print error messages to console if the API request fails - if not results then - msg.warn("Search did not return a results list") - return + table.insert(t, result) end - list.empty_text = "no results" - return results + return t 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 +---@alias PlayFlag 'play'|'append'|'new-window' -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 +---@param index number +---@param flag PlayFlag +local function play(index, flag) + if not index or not latest_search then return end + local item = latest_search.latest_results[index] -local function insert_channel(item) - list:insert({ - ass = ("👤 %s"):format(ass_escape(item.title)), - url = ("%s/channel/%s"):format(o.frontend, item.id) - }) + if flag == 'new-window' then + mp.commandv('run', 'mpv', item.url) + elseif flag == 'append' then + mp.command_native({"loadfile", item.url, 'append-play'}) + else + mp.command_native({"loadfile", item.url}) + end end -local function reset_list() - list.selected = 1 - list:clear() -end +---@type [string,string,PlayFlag][] +local custom_select_keybinds = { + {'Shift+Enter', 'select/append', 'append'}, + {'Shift+MBTN_LEFT', 'select/append/mbtn', 'append'}, + {'Ctrl+Enter', 'select/new-window', 'new-window'}, + {'Ctrl+MBTN_LEFT', 'select/new-window/mbtn', 'new-window'}, +} ---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 +local function show_results() + if not latest_search then return msg.error('no search results available to display') end + selection_open = true + + local items = {'~~ NEW SEARCH ~~', unpack(latest_search.display_list)} + ---@type PlayFlag + local flag = 'play' + + input.select({ + id = mp.get_script_name()..'/select-results', + prompt = ('Results for: %s | Filter: '):format(latest_search.query), + items = items, + default_item = 1, + keep_open = true, + opened = function() + msg.debug('Select prompt opened - adding keybinds') + + for _, keybind in ipairs(custom_select_keybinds) do + mp.add_forced_key_binding(keybind[1], keybind[2], function() + flag = keybind[3] + mp.commandv('keypress', 'enter') + end) + + -- This is necessary because of a race condition that sometimes + -- causes the console keybinds to take precedence over ours + -- despite us declaring ours after. + if keybind[1]:find('Shift') then + mp.add_timeout(0.5, function() + mp.add_forced_key_binding(keybind[1], keybind[2], function() + flag = keybind[3] + mp.commandv('keypress', 'enter') + end) + end) + end + end + end, + closed = function() + msg.debug('selection closed - removing keybinds') + for _, keybind in ipairs(custom_select_keybinds) do + mp.remove_key_binding(keybind[2]) + end + mp.remove_key_binding('_console_text') + end, + submit = function(i) + -- the first item is the option to do a new search + if i == 1 then + input.terminate() + mp.add_timeout(0.1, open_search_input) + else + play(i-1, flag) + end + + if flag == 'play' then + input.terminate() + selection_open = false + else + flag = 'play' + end + end, + }) end +---@param query string 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() + ---@type string[] + local display_list = {} + local results = process_ytdlp_results(search_ytdlp(query)) - 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 + --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: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 + for _, v in ipairs(results) do + if v.type == 'video' then + table.insert(display_list, ('%s —\t%s'):format(v.channelTitle, v.title)) + elseif v.type == 'channel' then + table.insert(display_list, ('~Channel~\t%s'):format(v.title)) + else + table.insert(display_list, ('~Playlist~\t%s'):format(v.title)) + end + end - mp.commandv("loadfile", list[list.selected].url, flag) - if flag == "replace" then list:close() end + latest_search = { + query = query, + latest_results = results, + display_list = display_list + } + show_results() 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:" }) +---@diagnostic disable-next-line: lowercase-global +function open_search_input() + input.get({ + id = mp.get_script_name()..'/enter-search-query', + prompt = 'Youtube Search:\n> ', + history_path = '~~state/youtube_search_history', + submit = function(line) + -- We must add this function to the event queue as the + -- 'closed' event (that removes the input handler) is sent at the + -- same time as submit. We must give it a chance to run before doing + -- the search so that the select prompt can receive input. + -- We are not using keep-open as there is still a delay + -- before the search completes and we don't want the input to remain open. + mp.add_timeout(0.1, function() search(line) end) + mp.osd_message(('Searching Youtube for "%s"'):format(line)) + input.terminate() + end + }) end -mp.add_key_binding("Ctrl+y", "yt", open_search_input) +mp.add_key_binding("Ctrl+y", "yt", function() + input.terminate() + mp.add_timeout(0.1, open_search_input) +end) mp.add_key_binding("Y", "youtube-search", function() - if not list.hidden then open_search_input() - else - list:open() - if #list.list == 0 then open_search_input() end - end + if selection_open or latest_search == nil then open_search_input() + else show_results() end end) +