#!/usr/bin/env sh #versioning system: #major.minor.bugs YTFZF_VERSION="2.4.1" # Scraping: query -> video json # User Interface: video json -> user selection -> ID # Player: ID -> video player # error codes: # 0: success # 1: general error # 2: invalid -opt or command argument, invalid argument for opt, configuration error # eg: ytfzf -c terminal (invalid scrape) # 3: missing dependency # 4: scraping error # 5: empty search # colors {{{ c_red="\033[1;31m" c_green="\033[1;32m" c_yellow="\033[1;33m" c_blue="\033[1;34m" c_magenta="\033[1;35m" c_cyan="\033[1;36m" c_reset="\033[0m" c_bold="\033[1m" #}}} #state variables : "${__is_submenu:=0}" "${__is_fzf_preview:=0}" : "${check_vars_exists:=${YTFZF_CHECK_VARS_EXISTS:-1}}" # Utility functions {{{ run_interface () { _interface="interface_${interface:-text}" $(printf "%s" "$_interface" | sed 's/-/_/g') "$ytfzf_video_json_file" "$ytfzf_selected_urls" unset _interface } download_thumbnails () { [ "$skip_thumb_download" -eq 1 ] && { print_info "Skipping thumbnail download\n"; return 0; } [ "$async_thumbnails" -eq 0 ] && print_info 'Fetching thumbnails...\n' curl_config_file="${session_temp_dir}/curl_config" [ -z "$*" ] && return 0 : > "$curl_config_file" for line in "$@"; do printf "url=\"%s\"\noutput=\"$thumb_dir/%s.jpg\"\n" "${line%%;*}" "${line##*;}" done >> "$curl_config_file" [ "$async_thumbnails" -eq 1 ] && use_silent="s" curl -${use_silent}fLZ -K "$curl_config_file" [ $? -eq 2 ] && curl -${use_silent}fL -K "$curl_config_file" } get_missing_thumbnails () { #this function could be done in a more pure-shell way, however it is extremely slow _tmp_id_list_file="${session_temp_dir}/all-ids.list" _downloaded_ids_file="${session_temp_dir}/downloaded-ids.list" jq -r '.[]|select(.thumbs!=null)|.ID' < "$video_json_file" | sort |uniq > "$_tmp_id_list_file" ids="$(jq -r '.[]|select(.thumbs!=null)|.thumbs + ";" + .ID' < "$video_json_file")" find "$thumb_dir" -type f | sed -n 's/^.*\///; s/\.jpg$//; /^[^\/]*$/p' | sort > "$_downloaded_ids_file" missing_ids="$(comm -3 "$_downloaded_ids_file" "$_tmp_id_list_file")" prepare_for_set_args #shellcheck disable=2086 set -- $missing_ids end_of_set_args modify_ifs "|" search_grep="$*" grep -E "(${search_grep:-${tab_space}})" </dev/null && views="|$(printf "%s" "${views#"|"}" | add_commas)" printf "%-${title_len}.${title_len}s\t" "$title" printf "%-${channel_len}.${channel_len}s\t" "$channel" printf "%-${dur_len}.${dur_len}s\t" "$duration" printf "%-${view_len}.${view_len}s\t" "$views" printf "%-${date_len}.${date_len}s\t" "$date" printf "%s" "$url" printf "\n" } thumbnail_video_info_text () { [ "$views" -eq "$views" ] 2>/dev/null && views="$(printf "%s" "$views" | add_commas)" [ -n "$title" ] && printf "\n ${c_cyan}%s" "$title" [ -n "$channel" ] && printf "\n ${c_blue}Channel ${c_green}%s" "$channel" [ -n "$duration" ] && printf "\n ${c_blue}Duration ${c_yellow}%s" "$duration" [ -n "$views" ] && printf "\n ${c_blue}Views ${c_magenta}%s" "$views" [ -n "$date" ] && printf "\n ${c_blue}Date ${c_cyan}%s" "$date" [ -n "$description" ] && printf "\n ${c_blue}Description ${c_reset}: %s" "$(printf "%s" "$description" | sed 's/\\n/\n/g')" } detach_cmd () { nohup "$@" > "/dev/null" 2>&1 & } source_scrapers () { prepare_for_set_args "," for _scr in $scrape; do if [ -f "$YTFZF_CUSTOM_SCRAPERS_DIR/$_scr" ]; then #shellcheck disable=SC1090 . "${YTFZF_CUSTOM_SCRAPERS_DIR}/$_scr" elif [ -f "$YTFZF_SYSTEM_ADDON_DIR/scrapers/$_scr" ]; then #shellcheck disable=SC1090 . "${YTFZF_SYSTEM_ADDON_DIR}/scrapers/$_scr" fi [ "$__is_fzf_preview" -eq 0 ] && command_exists "on_startup_$_scr" && "on_startup_$_scr" done end_of_set_args } add_commas () { awk ' { for(i=0; i0; i--){ printf "%s", substr($1, i, 1) } } ' } command_exists () { command -v "$1" > /dev/null 2>&1 } get_key_value() { sep="${3:- }" value="${1##*"${sep}""${2}"=}" #this can be used similarly to how you use $REPLY in bash KEY_VALUE="${value%%"${sep}"*}" printf "%s" "$KEY_VALUE" unset value [ "$KEY_VALUE" ] return "$?" } #capitalizes the first letter of a string title_str () { printf "%s" "$1" | dd bs=1 count=1 conv=ucase 2>/dev/null printf "%s\n" "${1#?}" } #backup shuf function, as shuf is not posix command_exists "shuf" || shuf () { awk -F'\n' 'BEGIN{srand()} {print rand() " " $0}' | sort -n | sed 's/[^ ]* //' } print_info () { # information goes to stdout ( does not disturb show_link_only ) #shellcheck disable=2059 [ "$log_level" -ge 2 ] && printf -- "$1" >&2 } print_warning () { #shellcheck disable=2059 [ "$log_level" -ge 1 ] && printf -- "${c_yellow}${1}${c_reset}" >&2 } print_error () { #shellcheck disable=2059 [ "$log_level" -ge 0 ] && printf -- "${c_red}${1}${c_reset}" >&2 } clean_up () { # print_info "cleaning up\n" # clean up only as parent process #kill ytfzf sub process{{{ #i think this needs to be written to a file because of sub-shells jobs_file="${TMPDIR:-/tmp}/the-jobs-need-to-be-written-to-a-file.list" jobs -p > "$jobs_file" while read -r line; do [ "$line" ] && kill "$line" 2> /dev/null done < "$jobs_file" #}}} if [ $__is_fzf_preview -eq 0 ]; then [ -d "$session_cache_dir" ] && [ "$keep_cache" -eq 0 ] && rm -r "$session_cache_dir" fi do_an_event_function on_clean_up } is_relative_dir () { case "$1" in ../*|./*|~/*|/*) return 0 ;; esac return 1 } die () { _return_status=$1 print_error "$2" exit "$_return_status" } trim_url () { while IFS= read -r _line;do printf '%s\n' "${_line##*"|"}" done } quick_menu () { fzf --ansi --reverse --prompt="$1" } quick_menu_ext (){ external_menu "$1" } quick_menu_scripting () { quick_menu "$1" } #the menu to use instead of fzf when -D is specified external_menu () { #dmenu extremely laggy when showing tabs tr -d '\t' | remove_ansi_escapes | dmenu -i -l 30 -p "$1" } search_prompt_menu () { printf "Search\n> " > /dev/stderr read -r _search printf "\033[1A\033[K\r%s\n" "> $_search" > /dev/stderr } search_prompt_menu_ext () { _search="$(printf '' | external_menu "Search: ")" } search_prompt_menu_scripting () { search_prompt_menu } search_prompt_menu_wrapper () { #the last sed is to set it to quick_menu if $interfce is "" (default) [ "$use_search_hist" -eq 1 ] && { _search="$(parse_search_hist_file < "$search_hist_file" | quick_menu_wrapper)"; return; } fn_name=search_prompt_menu"$(printf "%s" "${interface:+_$interface}" | sed 's/-/_/g')" #we could do : # $fn_name 2>/dev/null || [ "$?" -eq 127 ] && search_prompt_menu_ext #however, if we do that, the user won't get their error messages from their script if command_exists "$fn_name"; then $fn_name else search_prompt_menu_ext fi [ "$_search" ] } handle_playing_notifications (){ #if no notify-send push error to /dev/null if [ "$#" -le 1 ]; then unset IFS while read -r id title; do notify-send -c ytfzf -i "$thumb_dir/${id}.jpg" "Ytfzf Info" "Opening: $title" 2>/dev/null done <<-EOF $(jq -r '.[]|select(.url=="'"$*"'")|"\(.ID)\t\(.title)"' < "$ytfzf_video_json_file") EOF else notify-send -c ytfzf "ytfzf info" "Opening: $# videos" 2>/dev/null fi } parse_search_hist_file () { awk -F"${tab_space}" '{ if ($2 == "") {print $1} else {print $2} }' } quick_menu_wrapper () { prompt="$1" fn_name=quick_menu$(printf "%s" "${interface:+_$interface}" | sed 's/-/_/g') if command_exists "$fn_name"; then $fn_name "$prompt" else quick_menu_ext "$prompt" fi unset fn_name } load_extension () { ext=$1 loaded_extensions="$loaded_extensions $(printf "%s" "${ext##*/}" | sed 's/[ -]/_/g')" loaded_extensions="${loaded_extensions# }" if [ -f "${YTFZF_EXTENSIONS_DIR}/${ext}" ]; then #shellcheck disable=SC1090 . "${YTFZF_EXTENSIONS_DIR}/${ext}" elif [ -f "${YTFZF_SYSTEM_ADDON_DIR}/extensions/${ext}" ]; then #shellcheck disable=SC1090 . "${YTFZF_SYSTEM_ADDON_DIR}/extensions/${ext}" else #shellcheck disable=SC1090 . "$ext" fi return $? } load_sort_name () { _sort_name=$1 #shellcheck disable=SC1090 #shellcheck disable=SC2015 case "$_sort_name" in ./*|../*|/*|~/*) command_exists "$_sort_name" && . "$_sort_name" ;; *) if [ -f "${YTFZF_SORT_NAMES_DIR}/${_sort_name}" ]; then . "${YTFZF_SORT_NAMES_DIR}/${_sort_name}" elif [ -f "${YTFZF_SYSTEM_ADDON_DIR}/sort-names/${_sort_name}" ]; then . "${YTFZF_SYSTEM_ADDON_DIR}/sort-names/${_sort_name}" else false fi ;; esac rv="$?" unset "$_sort_name" return "$rv" } load_url_handler () { requested_url_handler=$1 if command_exists "$requested_url_handler"; then url_handler="${requested_url_handler:-multimedia_player}" else for path in "$YTFZF_URL_HANDLERS_DIR" "$YTFZF_SYSTEM_ADDON_DIR"; do [ -f "${path}/${requested_url_handler}" ] && url_handler="${path}/${requested_url_handler}" && return done die 2 "$optarg is not a url-handler\n" fi } load_interface () { requested_interface="$1" # if we don't check which interface, itll try to source $YTFZF_CUSTOM_INTERFACES_DIR/{ext,scripting} which won't work #shellcheck disable=SC1090 case "$requested_interface" in "ext"|"scripting"|"") interface=$requested_interface; true ;; ./*|../*|/*|~/*) [ -f "$requested_interface" ] && . "$requested_interface" && interface="${requested_interface##*/}"; false ;; *) if [ -f "${YTFZF_CUSTOM_INTERFACES_DIR}/${requested_interface}" ]; then interface=$requested_interface . "$YTFZF_CUSTOM_INTERFACES_DIR/$requested_interface" elif [ -f "${YTFZF_SYSTEM_ADDON_DIR}/interfaces/${requested_interface}" ]; then interface=$requested_interface . "${YTFZF_SYSTEM_ADDON_DIR}/interfaces/${requested_interface}" true fi ;; esac rv="$?" unset requested_interface return "$rv" } load_thumbnail_viewer () { _thumbnail_viewer="$1" case "$_thumbnail_viewer" in #these are special cases, where they are not themselves commands chafa-16|chafa|chafa-tty|catimg|catimg-256|imv|ueberzug|kitty|swayimg|mpv) thumbnail_viewer="$_thumbnail_viewer" ; true ;; ./*|/*|../*|~/*) thumbnail_viewer="$_thumbnail_viewer"; false ;; *) if [ -f "${YTFZF_THUMBNAIL_VIEWERS_DIR}/${_thumbnail_viewer}" ]; then thumbnail_viewer="${YTFZF_THUMBNAIL_VIEWERS_DIR}/${_thumbnail_viewer}" else thumbnail_viewer="${YTFZF_SYSTEM_ADDON_DIR}/thumbnail-viewers/$_thumbnail_viewer" fi; false esac rv="$?" unset _thumbnail_viewer return $rv } # Traps {{{ [ $__is_fzf_preview -eq 0 ] && trap 'clean_up' EXIT [ $__is_fzf_preview -eq 0 ] && trap 'exit' INT TERM HUP #}}} # }}} # Global Variables and Start Up {{{ # expansions where the variable is a string and globbing shouldn't happen should be surrounded by quotes # variables that cannot be empty should use := instead of just = #configuration handling {{{ : "${YTFZF_CONFIG_DIR:=${XDG_CONFIG_HOME:-$HOME/.config}/ytfzf}" : "${YTFZF_CONFIG_FILE:=$YTFZF_CONFIG_DIR/conf.sh}" : "${YTFZF_SUBSCRIPTIONS_FILE:=$YTFZF_CONFIG_DIR/subscriptions}" : "${YTFZF_THUMBNAIL_VIEWERS_DIR:=$YTFZF_CONFIG_DIR/thumbnail-viewers}" : "${YTFZF_SORT_NAMES_DIR:=$YTFZF_CONFIG_DIR/sort-names}" : "${YTFZF_CUSTOM_INTERFACES_DIR:=$YTFZF_CONFIG_DIR/interfaces}" : "${YTFZF_URL_HANDLERS_DIR:=$YTFZF_CONFIG_DIR/url-handlers}" : "${YTFZF_CUSTOM_THUMBNAILS_DIR:=$YTFZF_CONFIG_DIR/thumbnails}" : "${YTFZF_EXTENSIONS_DIR:=$YTFZF_CONFIG_DIR/extensions}" : "${YTFZF_SYSTEM_ADDON_DIR:=/usr/local/share/ytfzf/addons}" #}}} # Custom Scrapers {{{ : "${YTFZF_CUSTOM_SCRAPERS_DIR:=$YTFZF_CONFIG_DIR/scrapers}" #}}} set_vars () { check_exists="${1:-1}" #save the ecurrent environment so that any user set variables will be saved if [ "$check_exists" -eq 1 ]; then tmp_env=/tmp/ytfzf-env export -p > "$tmp_env" fi gap_space=" " # debugging log_level="2" # scraping useragent="Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.152 Safari/537.36" # menu options enable_submenus="1" submenu_opts="" submenu_scraping_opts="" enable_back_button="1" keep_vars=0 interface="" fancy_subs="0" fancy_subs_left="-------------" fancy_subs_right="${fancy_subs_right=$fancy_subs_left}" fzf_preview_side="left" thumbnail_viewer="ueberzug" # shortcuts download_shortcut="alt-d" video_shortcut="alt-v" audio_shortcut="alt-m" detach_shortcut="alt-e" print_link_shortcut="alt-l" show_formats_shortcut="alt-f" info_shortcut="alt-i" search_again_shortcut="alt-s" next_page_shortcut="alt-p" custom_shortcut_binds="" #interface design show_thumbnails="0" is_sort="0" skip_thumb_download="0" external_menu_len="210" is_loop="0" search_again="0" # Notifications notify_playing="0" # directories cache_dir="${XDG_CACHE_HOME:-$HOME/.cache}/ytfzf" keep_cache="0" # files hist_file="$cache_dir/watch_hist" search_hist_file="$cache_dir/search_hist" # history enable_hist="1" enable_search_hist="1" use_search_hist="0" # format options #variable for switching on sort (date) is_detach="0" is_audio_only="0" url_handler="multimedia_player" url_handler_opts="" info_to_print="" info_wait="0" info_wait_action="q" video_pref="bestvideo" audio_pref="bestaudio" show_formats="0" format_selection_screen="simple" format_selection_sort="height" scripting_video_count="1" is_random_select="0" is_auto_select="0" is_specific_select="0" # option parsing long_opt_char="-" # scrape scrape="youtube" #this comes from invidious' api thumbnail_quality="high" sub_link_count="2" invidious_instance="https://y.com.sb" yt_video_link_domain="https://youtube.com" search_sort_by="relevance" search_upload_date="" search_video_duration="" search_result_type="video" search_result_features="" search_region="US" pages_to_scrape="1" pages_start="1" nsfw="false" odysee_video_search_count="30" multi_search="0" custom_scrape_search_exclude="" scrape_search_exclude=" youtube-subscriptions S SI SL T youtube-trending H history ${custom_scrape_search_exclude} " max_thread_count="20" # When set to 1, instead of having to wait for thumbnails to download # The menu opens immediately while thumbnails download in the background async_thumbnails="0" #read from environment to reset any variables to what the user set if [ "$check_exists" -eq 1 ]; then _vars=$(awk -F"\n" ' BEGIN { vars="" } /^[^"]+="/{ vars=vars "\n" $1 } !/^[^"]+="/{ vars=vars "\t" $1 } END{ print vars; } ' < "$tmp_env") while read -r variable; do [ -z "$variable" ] && continue variable="${variable#export }" var_name="${variable%%=*}" #some shells do have quotation marks around the value, and some don't, these 2 lines must be seperate var_val="${variable#*=}" #since export -p is gauranteed to be able to be copied into a shell and work as intended, any value taht starts with a quote, wil be surrounded by quotes even if that particular shell doesn't normally do that. TLDR: this will only remove unimportant quotes var_val="${var_val#[\"\']}" #same goes for here var_val="${var_val%[\"\']}" export "$var_name"="${var_val}" done < The search-query can also be read from stdin OPTIONS: -h Show this help text -d Download the selected video(s) -m Only play audio -f Select a video format before playing -l Reopen the menu when the video stops playing -s After closing fzf make another search -q Use a search query from search history -L Show the link of selected video(s) -a Automatically select the first video -r Automatically select a random video -A Select all videos -S Automatically selects a specific video based on a given sed address, see ytfzf(1) for more info. -n