#!/bin/sh # qndl — yt-dlp download queue wrapper # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- notify() { # `--` ends option parsing so titles/bodies that look like flags (e.g. URLs # containing "-A...") aren't misread as notify-send options. notify-send -- "$1" "$2" } die() { notify "$1" "$2" exit 1 } get_cookies() { # yt-dlp doesn't have a native 'librewolf' option but reads its cookies as # a Firefox-style profile when given --cookies-from-browser firefox. case "${BROWSER:-}" in *firefox* | *librewolf*) printf 'firefox' ;; esac } get_url() { for _arg in "$@"; do case "$_arg" in https://* | http://*) printf '%s' "$_arg" return 0 ;; esac done _clip="$(xclip -selection clipboard -o 2>/dev/null)" case "$_clip" in https://* | http://*) printf '%s' "$_clip" return 0 ;; esac return 1 } validate_url() { case "$1" in https://* | http://*) return 0 ;; *) return 1 ;; esac } get_type() { for _arg in "$@"; do case "$_arg" in https://* | http://*) continue ;; -m | --music | m | music) printf 'music' return 0 ;; -v | --video | v | video) printf 'video' return 0 ;; -r | --restore | r | restore) printf 'restore' return 0 ;; *) printf '%s' "$_arg" return 0 ;; esac done } get_filename() { _url="$1" _cookies="$(get_cookies)" if [ -n "$_cookies" ]; then _fname="$(yt-dlp --simulate --no-playlist --print '%(filename)s' --cookies-from-browser "$_cookies" "$_url" 2>/dev/null | head -n 1)" else _fname="$(yt-dlp --simulate --no-playlist --print '%(filename)s' "$_url" 2>/dev/null | head -n 1)" fi basename "$_fname" } enqueue() { _dl_type="$1" _url="$2" shift 2 _cookies="$(get_cookies)" _music_dir="${XDG_MUSIC_DIR:-$HOME/Music}" # Build argument list without string concatenation chains set -- \ --continue \ --embed-metadata \ --ignore-errors \ --no-force-overwrites \ --verbose \ "$@" if [ -n "$_cookies" ]; then set -- --cookies-from-browser "$_cookies" "$@" fi # Quote each arg for safe passage through bash -c _quoted_args="" for _a in "$@"; do _escaped=$(printf '%s' "$_a" | sed "s/'/'\\\\''/g") _quoted_args="$_quoted_args '${_escaped}'" done # `--` ends option parsing so URLs whose video ID starts with '-' aren't treated as flags. _ytdl_cmd="yt-dlp${_quoted_args} -- '$(printf '%s' "$_url" | sed "s/'/'\\\\''/g")'" _idnum="$(tsp bash -c "$_ytdl_cmd")" pkill -RTMIN+16 "${STATUSBAR:-dwmblocks}" # tsp -D runs next job only if dependency succeeded (exit 0) # Success notification — runs only if download succeeds tsp -D "$_idnum" notify-send -- "✅ ${_dl_type} download complete:" "$_url" # Failure notification — waits for job, checks its exit status tsp bash -c 'tsp -w "$1"; _exit=$(tsp -i "$1" | sed -n "s/.*exit code //p"); [ -n "$_exit" ] && [ "$_exit" != "0" ] && notify-send -- "❌ Failed to download:" "$2"' -- "$_idnum" "$_url" if [ "$_dl_type" = "music" ]; then tsp -D "$_idnum" bash -c "mpc update --wait && find '$_music_dir' -name '*.mp3' | sed 's|$_music_dir/||' | sort > '$HOME/.config/mpd/playlists/entire.m3u'" fi } # --------------------------------------------------------------------------- # Playlist # --------------------------------------------------------------------------- handle_playlist() { _url="$1" _download_type="$2" _output_dir="$3" _base_output_format="$4" case "$_url" in *playlist* | *list=*) _pl_choice="$(printf 'playlist\na content' | dmenu -i -p 'Download entire playlist or just this content?')" ;; *) printf '%s\n' '--no-playlist' printf '%s\n' "$_base_output_format" return 0 ;; esac case "$_pl_choice" in playlist) printf '%s\n' '--yes-playlist' if [ "$_download_type" = "video" ]; then _channel="$(yt-dlp --print '%(channel)s' "$_url" 2>/dev/null | head -n 1 | sed 's/, /,/g;s/[\/:*?"<>| ]/-/g' | tr '[:upper:]' '[:lower:]')" _playlist="$(yt-dlp --print '%(playlist_title)s' "$_url" 2>/dev/null | head -n 1 | sed 's/, /,/g;s/[\/:*?"<>| ]/-/g' | tr '[:upper:]' '[:lower:]')" _subdir="${_channel}/${_playlist}" mkdir -p "${_output_dir}/${_subdir}" printf '%s\n' "${_output_dir}/${_subdir}/%(playlist_index)02d_%(title)s [%(id)s].%(ext)s" else printf '%s\n' "$_base_output_format" fi ;; *) # "a content" or dmenu cancelled printf '%s\n' '--no-playlist' printf '%s\n' "$_base_output_format" ;; esac } # --------------------------------------------------------------------------- # Download Modes # --------------------------------------------------------------------------- download_music() { _url="$1" _output_dir="${XDG_MUSIC_DIR:-$HOME/Music}" _archive="${XDG_DOTFILES_DIR:-$HOME/.dotfiles}/global/Music/.music.txt" _format="${_output_dir}/%(artists.0|Unknown Artist)s/%(album|Unknown Album)s/%(title)s.%(ext)s" _pl_result="$(handle_playlist "$_url" "music" "$_output_dir" "$_format")" _pl_flag="$(printf '%s' "$_pl_result" | head -n 1)" _fmt="$(printf '%s' "$_pl_result" | tail -n 1)" _filename="$(get_filename "$_url")" notify "📥 Queuing music download:" "$_filename" enqueue "music" "$_url" \ "$_pl_flag" \ --extract-audio \ --audio-format mp3 \ --audio-quality 0 \ --download-archive "$_archive" \ --output "$_fmt" } download_video() { _url="$1" _output_dir="${XDG_VIDEOS_DIR:-$HOME/Videos}" _format="${_output_dir}/%(title)s [%(id)s].%(ext)s" _video_ext="$(printf 'best\n60fps\n30fps\nmp4\nmkv' | dmenu -i -p 'Choose an encoding (default: 1080p)')" || die "⛔ Video encoding selection cancelled" "" _format_val="" _recode_ext="" case "$_video_ext" in best) _format_val="bestvideo+bestaudio/best" ;; 60fps) _format_val="((bv*[fps=60]/bv*)[height<=1080]/(wv*[fps=60]/wv*))+ba/(b[fps=60]/b)[height<=1080]/(w[fps=60]/w)" ;; 30fps) _format_val="((bv*[fps=30]/bv*)[height<=1080]/(wv*[fps=30]/wv*))+ba/(b[fps=30]/b)[height<=1080]/(w[fps=30]/w)" ;; *) _format_val="bestvideo+bestaudio/best" _recode_ext="$_video_ext" ;; esac _pl_result="$(handle_playlist "$_url" "video" "$_output_dir" "$_format")" _pl_flag="$(printf '%s' "$_pl_result" | head -n 1)" _fmt="$(printf '%s' "$_pl_result" | tail -n 1)" _filename="$(get_filename "$_url")" notify "📥 Queuing video download:" "$_filename" if [ -n "$_recode_ext" ]; then enqueue "video" "$_url" \ "$_pl_flag" \ --buffer-size 1M \ --embed-thumbnail \ --no-sponsorblock \ --format "$_format_val" \ --recode-video "$_recode_ext" \ --output "$_fmt" else enqueue "video" "$_url" \ "$_pl_flag" \ --buffer-size 1M \ --embed-thumbnail \ --no-sponsorblock \ --format "$_format_val" \ --output "$_fmt" fi } restore_archive() { _output_dir="${XDG_MUSIC_DIR:-$HOME/Music}" _archive="${XDG_DOTFILES_DIR:-$HOME/.dotfiles}/global/Music/.music.txt" _titles="${XDG_DOTFILES_DIR:-$HOME/.dotfiles}/global/Music/.music_titles.txt" _format="${_output_dir}/%(artists.0|Unknown Artist)s/%(album|Unknown Album)s/%(title)s.%(ext)s" [ ! -f "$_archive" ] && die "⛔ Archive not found" "$_archive" # Build dmenu lines: "Artist - Title [VIDEOID]" if title is cached, else "[VIDEOID]" # Archive uses space ("youtube ID"); titles file uses tab ("IDtitle"). if [ -f "$_titles" ]; then _display="$(awk ' NR==FNR { tab = index($0, "\t") if (tab > 0) titles[substr($0, 1, tab-1)] = substr($0, tab+1) next } { sp = index($0, " ") if (sp == 0) next id = substr($0, sp+1) if (id == "") next if (id in titles) print titles[id] " [" id "]" else print "[" id "]" } ' "$_titles" "$_archive")" else _display="$(awk '$2 != "" { print "[" $2 "]" }' "$_archive")" fi _total="$(printf '%s\n' "$_display" | grep -c .)" _choice="$(printf 'all\n%s' "$_display" | dmenu -i -l 20 -p "Restore which song? ($_total total)")" [ -z "$_choice" ] && return 0 if [ "$_choice" = "all" ]; then _selected="$(awk '$2 != "" { print $2 }' "$_archive")" else # Extract video ID from trailing "[VIDEOID]" (11-char YouTube id, anchored at end) _selected="$(printf '%s' "$_choice" | sed -n 's/.*\[\([A-Za-z0-9_-]\{11\}\)\][[:space:]]*$/\1/p')" [ -z "$_selected" ] && die "⛔ Could not parse video ID from selection" "$_choice" fi _sel_total="$(printf '%s\n' "$_selected" | grep -c .)" notify "⏳ Queueing $_sel_total restore(s)" "via tsp" _tmpfile="$(mktemp)" printf '%s\n' "$_selected" >"$_tmpfile" while IFS= read -r _id; do [ -z "$_id" ] && continue enqueue "music" "https://www.youtube.com/watch?v=$_id" \ --no-playlist \ --extract-audio \ --audio-format mp3 \ --audio-quality 0 \ --output "$_format" done <"$_tmpfile" rm -f "$_tmpfile" notify "✅ All $_sel_total job(s) queued" "tsp will process them sequentially" } # --------------------------------------------------------------------------- # Entry Point # --------------------------------------------------------------------------- main() { _type="$(get_type "$@")" case "$_type" in music) _url="$(get_url "$@")" || die "⛔ No URL provided" "Pass a URL or copy one to the clipboard." validate_url "$_url" || die "⛔ Invalid URL format" "$_url" download_music "$_url" ;; video) _url="$(get_url "$@")" || die "⛔ No URL provided" "Pass a URL or copy one to the clipboard." validate_url "$_url" || die "⛔ Invalid URL format" "$_url" download_video "$_url" ;; restore) restore_archive ;; "") die "⛔ No type specified" "Provide: music, video, or restore." ;; *) die "⛔ Invalid type: $_type" "Recognized types: music, video, restore." ;; esac } main "$@"