#! /bin/bash # # ptgdp - Play the Goddamned Playlist # Copyright (C) 2019 Vintage Salt # # Distributed under terms of the MIT license. # # TODO: # * Reorganize mpd integration into abstract functions # Variables _name="$(basename -- "$0")" _tmpdir="${XDG_CACHE_HOME:-$HOME/.cache}/$_name" _tmpfile="$_tmpdir/tmpfile-$(cat /dev/urandom | tr -cd 'a-f0-9' | head -c 12)" _xdguserdirs="${XDG_CONFIG_HOME:-$HOME/.config}/user-dirs.dirs" [ -z "$XDG_MUSIC_DIR" ] && [ -f "$_xdguserdirs" ] && source "$_xdguserdirs" _musicdir="${XDG_MUSIC_DIR:-$HOME/Music}" _ptgdpmusicdir="$_musicdir/${PTGDP_MUSIC_DIR:-PTGDP Songs}" declare -a _queue # Helper functions log() { [ -z "$1" ] && return 1 # Message body printf "%s: %s\\n" \ "$_name" \ "$1" } error() { [ -z "$1" ] && return 1 # Message body # 2: Exit code printf "%s: \\e[31m%s\\e[0m\\n" \ "$_name" \ "$1" [ -n "$2" ] && exit "${2:-1}" } notify() { [ -z "$_optrofi" ] && return 0 [ -z "$1" ] && return 1 # Title [ -z "$2" ] && return 2 # Body local urg="${3:-normal}" # Urgency local icon="${4:-dialog-information}" # Icon local timeout="${5:-3000}" # Timeout in milliseconds notify-send \ -a "$_name" \ -i "$icon" \ -u "$urg" \ -t "$timeout" \ "$1" \ "$2" > /dev/null 2>&1 } checkforsong() { # $1: A song name to validate [ -z "$1" ] && return 1 # Very rudimentary implementation # TODO: Make this a lot more thorough, maybe ensure MIME is good for file in "$_ptgdpmusicdir/$1"*; do if [ -f "$file" ]; then _return="$file" return 0 fi done return 1 } cachesong() { # $1: A song query to download # $_return: The relative file path to song from XDG_MUSIC_DIR # If in a dry run, $_return is the title of the video, if extracted # Note that this will return 100 if the file is cached, so do not assume only 0 is success [ -z "$1" ] && return 1 # Clean up tmpfile rm "$_tmpfile"* > /dev/null 2>&1 sanitize "$1" filename="$_ptgdpmusicdir/$_return" if [ -z "$_optdryrun" ]; then if ! checkforsong "$_return"; then # We need to download the song youtube-dl \ --add-metadata \ --audio-format "best" \ --geo-bypass \ --playlist-items 1 \ -x \ -o "$_tmpfile.%(ext)s" \ ytsearch:"$1" \ > /dev/null 2>&1 & if wait $!; then for file in "$_tmpfile"*; do [ -f "$file" ] && local extension="$file" break done extension="${extension##*/}" extension="${extension##*.}" filename="$filename.$extension" mv "$_tmpfile"* "$filename" _return="$filename" return 0 else return 1 fi else #_return is already an existing cached file, so just die return 100 fi else output="$( youtube-dl \ --get-title \ --geo-bypass \ --playlist-items 1 \ ytsearch:"$1" 2>&1 )" exitcode="$?" if [ $exitcode -gt 0 ]; then return 1 fi if ! [ "$output" = "${output#*WARNING}" ]; then # Title could not be extracted unset _return else # Title was found _return="$output" fi return 0 fi } queuesong() { # $1: A song query to queue # returns 0 if successful, 100 if cached, 1 if error # Adds a song to $_queue [ -z "$1" ] && return 2 cachesong "$1" local errorcode=$? if [ -z "$_optdryrun" ]; then case $errorcode in 0) # Downloaded log "Downloaded song \"$1\"" ;; 100) # Cached log "Using cached song \"$1\"" ;; *) # Failed dlfailure+=1 notify "Could not download song" \ "youtube-dl did not download a song for \"$1\"" \ normal dialog-error 3000 error "Could not download song \"$1\"" continue ;; esac _queue+=("$_return") else case $errorcode in 0) # Success if [ -n "$_return" ]; then log "$1 - \"$output\"" else log "$1 parsed, but title could not be extracted" fi ;; *) # Failure error "Could not find song \"$1\"" ;; esac fi return $errorcode } execqueue() { # Plays all songs in _queue mpc update --wait > /dev/null 2>&1 for file in "${_queue[@]}"; do file=${file##$_musicdir/} mpc add "$file" > /dev/null 2>&1 || error "Could not add file: \"$file\"" done if [ -n "$_optautoplay" ]; then mpc play > /dev/null 2>&1 unset _optautoplay fi } validatedeps() { # $@: Dependencies to validate for prog in "$@"; do if ! command -v "$prog" > /dev/null 2>&1; then _return="$prog" return 1 fi done return 0 } validateline() { # $1: A line to check for comments or whitespace # Strictly speaking, this removes all whitespace from the line # While not *exactly* what I'm looking for, it's sufficient for trimming whitespace lines local linenows=${1//[[:space:]]} if ! [ "${1#\#}" = "$1" ] || [ -z "$linenows" ]; then return 1 fi return 0 } validateplaylistenv() { [ -z "$1" ] && return 1 [ -e "$1" ] || error "Playlist \"$1\" does not exist" 50 [ -f "$1" ] || error "Playlist \"$1\" is not a file" 50 [ -r "$1" ] || error "Cannot read playlist \"$1\"" 51 if [ -z "$_optdownloadonly" ] && ! mpc status > /dev/null 2>&1; then notify "Could not communicate with MPD" \ "MPD connection was refused. Ensure your configuration is correct and the daemon is currently running." \ normal dialog-error 3000 error "Failed to communicate with MPD" 52 fi } sanitize() { [ -z "$1" ] && return 1 # String to strip special chars from _return="${1//[^ a-zA-Z0-9\[\]|()_-]/}" } # Traps trapexit() { kill $(jobs -p) > /dev/null 2>&1 [ -n "$_tmpdir" ] && rm "$_tmpfile"* > /dev/null 2>&1 } # Critical functions clearcache() { [ -n "$_ptgdpmusicdir" ] && rm "$_ptgdpmusicdir"/* > /dev/null 2>&1 log "Cache has been emptied" } helptext() { cat << EOF Usage: $_name [OPTION] Use youtube-dl and mpc to queue up a playlist given a file of only search queries. The first result found is the one that will be downloaded. Downloaded files are cached in your Music folder under "PTGDP Songs" for offline use. -f The playlist file to load. The file should be plaintext containing a YouTube search query on each line. Comments are supported and prepended with # -c Clears the cache (which can become quite large) -d Download only; don't queue anything up Conflicts with -p -D Dry run; parse out all songs, downloaded or not, and print out the resolved names. Useful for testing, as YouTube searches can sometimes be finicky. -p Play the playlist after it is enqueued. Conflicts with -d -s Shuffle the playlist -r Start up rofi, if installed, and present a listing of all .gdp files in the given directory. If notify-send is installed, this will also send notifications pertaining to playlist status. -R As above, but also parse out the selected playlist and present a listing of individual songs. The argument can be a file in this mode -h Print this help text Environment Variables In addition to XDG-spec variables (XDG_CACHE_HOME, user-dirs.dirs, etc.), ptgdp also respects an additional variable: PTGDP_MUSIC_DIR The subdirectory in XDG_MUSIC_DIR to save music to Copyright (c) 2019 rehashedsalt@cock.li Licensed under the MIT license EOF } rofimenu() { validatedeps rofi || error "$_return is not currently installed" 1 if ! [ -d "$_optrofi" ]; then if [ -z "$_optrofisong" ]; then error "Could not open directory \"$_optrofi\"" 2 else [ -f "$_optrofi" ] || error "Could not open file \"$_optrofi\"" 2 fi fi files=$(find "$_optrofi" -type f -name \*.gdp) if [ -n "$files" ]; then # Strip file suffixes for a cleaner menu playlists="" while read file; do filebase="$(basename -- "$file")" filebase="${filebase%.gdp}" [ -n "$playlists" ] && playlists+=$'\n' playlists+="$filebase" done <<< "$files" else notify "No playlists found" \ "No playlists could be found in directory \"$_optrofi\"." \ normal dialog-error 5000 error "No playlists found" 61 fi prompt="$_name list" [ -n "$_optautplay" ] && prompt="$prompt ap" choice="$(rofi -dmenu -i -p "$prompt" <<< "$playlists" 2>/dev/null)" [ -z "$choice" ] && error "User aborted at selection" 62 if [ -z "$_optrofisong" ]; then playlist "$_optrofi"/"$choice".gdp else rofiplaysong "$_optrofi"/"$choice".gdp fi return 0 } rofiplaysong() { validateplaylistenv "$1" || return $? local songs="" while read line; do validateline "$line" || continue if [ -z "$songs" ]; then songs="$line" else songs="$songs"$'\n'"$line" fi done < "$1" IFS=$'\n' prompt="$_name song" [ -n "$_optautplay" ] && prompt="$prompt ap" choice="$(rofi -dmenu -i -p "$prompt" <<< "$songs" 2>/dev/null)" if [ -z "$choice" ]; then # Assume user is looking for another list # Kick him back to the main menu rofimenu fi log "User picked: $choice" queuesong "$choice" [ "$?" = "1" ] && return 62 execqueue return 0 } playlist() { validateplaylistenv "$1" || return $? [ -z "$_optdryrun" ] && local -a queue # An array of songs to later enqueue into mpd local -i dlexist=0 local -i dlsuccess=0 local -i dlfailure=0 local -i maxlines=0 while read line; do validateline "$line" || continue maxlines+=1 done < "$1" log "Enqueued playlist \"$1\" with $maxlines songs" while read line; do [ -z "$line" ] && continue validateline "$line" || continue rm "$_tmpfile"* > /dev/null 2>&1 # Do the do queuesong "$line" # What did we do? local errorcode=$? case $errorcode in 0) dlsuccess+=1 ;; 100) dlexist+=1 ;; *) dlfailure+=1 ;; esac done < <(if [ -n "$_optshuffle" ]; then shuf "$1"; else cat "$1"; fi) if [ "$dlexist" = "0" ] && [ "$dlsuccess" = "0" ] && [ -z "$_optdryrun" ]; then notify "Failed to download playlist" \ "The playlist in its entirety could not be downloaded. Ensure that youtube-dl is up to date, you have a valid internet connection, and your search queries pull up results" \ normal dialog-error 10000 elif [ -z "$_optdryrun" ]; then if [ -z "$_optdownloadonly" ]; then notify "Finished downloading playlist" \ "The playlist has been downloaded and will be added to mpd shortly" log "Finished downloading: $dlexist cached, $dlsuccess downloaded, $dlfailure failed" execqueue else notify "Finished precaching" \ "Your songs have been cached and are ready for offline playback" log "Finished downloading: $dlexist cached, $dlsuccess downloaded, $dlfailure failed" fi else log "Finished dry run: $dlsuccess succeeded, $dlfailure failed" fi } # Main main() { # Boostrapping and setup validatedeps youtube-dl basename || error "Critical dependency $_return was not met" 1 mkdir -p "$_tmpdir" mkdir -p "$_ptgdpmusicdir" trap trapexit EXIT # Actual program stuff while getopts ":cdDf:pr:R:sh" opt; do case $opt in c) clearcache exit $? ;; d) _optdownloadonly=1 ;; D) _optdryrun=1 ;; f) _optfile="$OPTARG" ;; p) _optautoplay=1 ;; r) _optrofi="$OPTARG" ;; R) _optrofi="$OPTARG" _optrofisong=1 ;; s) _optshuffle=1 ;; h) helptext exit $? ;; :) error "Option requires argument: -$OPTARG" 2 ;; *) error "Invalid option: -$OPTARG" 2 ;; esac done [ -z "$_optdownloadonly" ] && [ -z "$_optdryrun" ] && validatedeps mpc || error "$_return is required outside of dry- and download-only runs" [ -n "$_optfile" ] && [ -n "$_optrofi" ] && error "Flags -f and -r conflict" 2 [ -n "$_optdownloadonly" ] && [ -n "$_optautoplay" ] && error "Flags -d and -p conflict" 2 if [ -n "$_optrofi" ]; then rofimenu; exit $?; fi if [ -n "$_optfile" ]; then playlist "$_optfile"; exit $?; fi error "Nothing to do" 0 } main "$@"