This script tries to be an interactive and user-friendly solution for managing and downloading GitHub repository (latest) assets.
Beware:
- It is a work in progress although it works well most of the time.
- If you abuse Github by sending requests to the server, the Github API will ban you for a while.
- First of all, the script relies on having fzf, jq and curl installed on your system.
- It creates a dedicated download directory (GH-DOWN) within the user's default downloads folder. (Change to suit your needs).
- Manages two local lists: .GH-REPOS and .GH-TOPICS. The former stores your favorite repositories, while the latter contains your dictionary of topic searches.
- From fzf window, you can switch to .GH-REPOS with Ctrl-r, while Ctrl-t is used to switch to .GH-TOPICS.
- From Topics list, you can discover new repos by typing any topic and ADD it (Ctrl-a) to the .GH-REPOS list. Then you can search for it (Enter) or REMOVE it (Ctrl-Del)
- Similarly, once a search for repositories matching the searched term has been launched, you can add them to the repos list using the shortcut Ctrl-a.
- In the repository list, you can delete the highlighted repository (Ctrl-Del) or search for its latest release to download (Enter).
- Finally, once the latest release assets are presented (if they exist), you can multi-select them and download them all at once. *
*In case it's an AppImage, its extension is normalized and it is marked as executable.
^^Tip: If you have some repos added to your list, RATHER THAN BROWSING FROM ONE TO ANOTHER, USE YOUR MOUSE to jump to a particular repo, avoiding sending unnecesary request to Github
Script code follows:
Code: Select all
#!/bin/bash
#
# gh-down: My GitHub latest release downloader
# v2025-01 M. Eerie --> forum.porteus.org
# ------------------------------------------------------------------------------
#set -x
# Helper functions to show messages with colors
error() { printf "\e[1;31m%s\e[0m\n" "$@"; }
success() { printf "\e[1;32m%s\e[0m\n" "$@"; }
warn() { printf "\e[1;33m%s\e[0m\n" "$@"; }
notify() { printf "\e[1;34m%s\e[0m\n" "$@"; }
confirm() { read -n 1 -rsp "$(printf '\e[1;36m%s\e[0m' "$1 [y/*]: ")" && echo && [[ "${REPLY,,}" == y ]]; }
# ------------------------------------------------------------------------------
# Function to show progress with a green fill box
show_progress() {
local total=$1 # Total size
local progress=$2 # Current progress
local width=50 # Width of the bar
local green="\033[32m" # Green color
local reset="\033[0m" # Reset
local percentage=$(( (progress * 100) / total ))
[[ "$percentage" -gt 100 ]] && percentage=100 # Prevent division by 0 TODO
local blocks=$(( (width * progress) / total ))
local completed=$(printf "%${blocks}s" | sed 's/ /█/g')
local remaining=$(printf "%$((width - blocks))s" | sed 's/ /░/g')
printf "\r[${green}${completed}${reset}${remaining}] %3d%%" $percentage
}
# Download directory
DEF_DOWNLOAD_DIR="$(xdg-user-dir DOWNLOAD)"
DOWNLOAD_DIR="${DEF_DOWNLOAD_DIR:-$HOME}/GH-DOWN" # Adjust to suit your needs
mkdir -p "$DOWNLOAD_DIR"
# Update local repository and topic lists
_REPOS="$HOME/.GH-REPOS"
_TOPICS="$HOME/.GH-TOPICS"
update_list() {
local list="$1"
[[ ! -f "$list" ]] && touch "$list"
awk '!seen[$0]++' "$list" > "$list.tmp" && mv "$list.tmp" "$list"
}
# ------------------------------------------------------------------------------
# Function to toggle between repository and topic lists using fzf
list_mode() {
local MODE="${1:-repos}" FILE PREVIEW PROMPT HEADER OUTPUT KEY SELECTED
MODE="${MODE##*-}"
while true; do
if [[ "$MODE" == "repos" ]]; then
FILE="$_REPOS"
PROMPT="Select Repository: "
HEADER="=== REPOS ==="
PREVIEW="echo -e '[ENTER: select] [CTRL-A: Add] [CTRL-DELETE: Delete] [CTRL-T: Topics]\n'; "
PREVIEW+="curl -s https://api.github.com/repos/{} | jq -r '.description + \"\n\nLatest release: \" + (.updated_at | fromdate | strftime(\"%Y-%m-%d\"))'"
else
FILE="$_TOPICS"
PROMPT="Select/Search new terms: "
HEADER="=== TOPICS ==="
PREVIEW="echo -e '[ENTER: select] [CTRL-A: Add] [CTRL-DELETE: Delete] [CTRL-T: Repositories]\n'"
fi
OUTPUT=$(fzf \
--bind "ctrl-a:execute-silent(echo {q} >> $FILE && sort -u $FILE -o $FILE)+reload-sync(cat $FILE)" \
--bind "ctrl-delete:execute-silent(sed -i 's_'"{}"'__; /^$/d' $FILE)+reload-sync(cat $FILE)" \
--bind home:top \
--bind end:last \
--bind "del:clear-query" \
--bind "?:toggle-preview" \
--expect "ctrl-c,ctrl-r,ctrl-t,esc" \
--prompt "$PROMPT" \
--preview-window "down,wrap,25%" \
--preview "$PREVIEW" \
--header "$HEADER" \
--reverse \
< "$FILE")
KEY=$(head -n1 <<< "$OUTPUT")
SELECTED=$(tail -n +2 <<< "$OUTPUT")
# Reset $SELECTED when changing mode
if [[ "$KEY" == "ctrl-t" || "$KEY" == "ctrl-r" ]]; then
SELECTED=""
fi
case "$KEY" in
"ctrl-t") MODE="topics" ;;
"ctrl-r") MODE="repos" ;;
"ctrl-c") exit 1 ;; #TODO
"esc") break ;;
esac
[[ -n "$SELECTED" ]] && case "$MODE" in
repos) download_assets "$SELECTED" ;;
topics) discover_repos "$SELECTED" ;;
*) exit ;;
esac
done
}
urlencode() {
# OLD echo ${1// /"%20"}
echo $(jq -rn --arg str "$1" '$str|@uri')
}
# ------------------------------------------------------------------------------
# Function discover_repos:
# Runs from the topic list (or from the prompt) and searches
# repositories on GitHub using the selected term. If any are found,
# allows the user to select a repository and download its assets.
discover_repos() {
local repos query="$1"
repos=$(curl -s "https://api.github.com/search/repositories?q=$(urlencode "$query")&sort=stars&order=desc" \
| jq -r '.items[].full_name' \
| fzf --reverse \
--header="Results for: $query" \
--multi \
--marker='🟊 ' \
--bind "del:clear-query" \
--bind home:top \
--bind end:last \
--bind "ctrl-a:execute-silent(echo {+} | tr ' ' '\n' >> $_REPOS && sort -u $_REPOS -o $_REPOS)" \
--preview-window="down,wrap,25%" \
--preview="echo {} && curl -s https://api.github.com/repos/{} | jq -r '.description' && \
if grep -q '^{}$' $_REPOS; then echo -e '\033[32m[+]Added\033[0m'; fi")
if [[ -z "$repos" ]]; then
error "No repository found for the term: $query"
return 1
fi
#sleep 2 # Limit requests to GitHub API
download_assets "$repos"
}
# -------------------------------------------------------------------
# Function download_assets:
# Called with the full name of a repository. Gets the latest release
# and allows the user to select (with support for multiple items) the assets to download.
download_assets() {
local repo="$1" response tag_name selected_assets asset_name asset_info asset_url asset_size temp_file app_name
response=$(curl -s "https://api.github.com/repos/$repo/releases/latest")
if [[ -z "$response" || "$(echo "$response" | jq -r '.message')" == "Not Found" ]]; then
error "No release found for the repository '$repo'."
return 1
fi
tag_name=$(echo "$response" | jq -r '.tag_name')
selected_assets=$(echo "$response" | jq -r '.assets[]?.name' \
| fzf --multi --reverse --marker=" " --bind "del:clear-query" --header="[TAB] Select items to download (Release: $tag_name)")
[[ -z "$selected_assets" ]] && return 0
while IFS= read -r asset_name; do
asset_info=$(echo "$response" | jq -r ".assets[] | select(.name == \"$asset_name\")")
asset_url=$(echo "$asset_info" | jq -r '.browser_download_url')
asset_size=$(echo "$asset_info" | jq -r '.size')
notify "Downloading: $asset_name ($tag_name, $asset_size bytes)"
temp_file=$(mktemp)
(
curl -L "$asset_url" --output "$temp_file" 2>/dev/null &
local curl_pid=$!
while kill -0 $curl_pid 2>/dev/null; do
local downloaded
downloaded=$(stat -c%s "$temp_file" 2>/dev/null || echo 0)
show_progress "$asset_size" "$downloaded"
sleep 0.1
done
show_progress "$asset_size" "$asset_size" # Ensure full bar
)
printf "\n"
mv "$temp_file" "$DOWNLOAD_DIR/$asset_name"
# AppImage: Normalize extension and mark as executable
extension="${asset_name##*.}"
if [[ "${extension,,}" == "appimage" ]]; then
app_name="${asset_name%.*}.AppImage"
mv -f "$DOWNLOAD_DIR/$asset_name" "$DOWNLOAD_DIR/$app_name" 2>/dev/null
chmod +x "$DOWNLOAD_DIR/$app_name"
fi
sleep 1 # Limit requests to GitHub API 403 Forbidden
done <<< "$selected_assets"
success "Downloads completed."
}
# ------------------------------------------------------------------------------
# Main function
main() {
# Check necessary dependencies
for dep in fzf jq curl; do
command -v "$dep" >/dev/null 2>&1 || { error "Dependency '$dep' is not installed."; exit 1; }
done
[[ $# -gt 1 ]] && { error "Usage: $0 [-r|--repos] [-t|--topics]"; exit 1; }
update_list "$_REPOS"
update_list "$_TOPICS"
case "$1" in
-r|--repos) list_mode "repos" ;;
-t|--topics) list_mode "topics" ;;
*) list_mode ;;
esac
[[ $? -ne 0 ]] && warn "Aborted!!..." || success "Exiting..."
}
# Execute the script
main "$@"