#! /bin/bash # # ptgdp - Play the Goddamned Playlist # Copyright (C) 2019 Vintage Salt # # Distributed under terms of the MIT license. # set -e # Import user-dirs.dirs _xdguserdirs="${XDG_CONFIG_HOME:-$HOME/.config}/user-dirs.dirs" [ -z "$XDG_MUSIC_DIR" ] && [ -r "$_xdguserdirs" ] && source "$_xdguserdirs" unset _xdguserdirs # Read-only set-once variables declare -r _name="$(basename -- "$0")" declare -r _musicdir="${XDG_MUSIC_DIR:-$HOME/Music}" declare -r _ptgdpmusicdir="$_musicdir/PTGDP Songs" declare -ra _supportedbackends=("mpd" "audacious") # May-need-amended variables declare _mpdroot="$_musicdir" # Options declare -A _config=( [backend]="mpd" [ytdl_args]="--geo-bypass" ) declare _optconfigfile="${XDG_CONFIG_HOME:-$HOME/.config}/${_name}.conf" declare -i _optautoplay=0 declare -i _optdlonly declare -i _opthelp declare -i _optmachinemode declare -i _optverbose # Working variables declare -a _queue declare -a _args declare _return # Helper functions log() { # Print a line to the terminal if _optverbose is greater than $2 # $2 defaults to 0 # loglevel 0: Daily-use messages # loglevel 1: Detailed but not quite debugging # loglevel 2: Definitely debugging [ -z "$1" ] && return 1 local -i outstream=1 [ -n "$_optmachinemode" ] && outstream=2 if (( _optverbose >= ${2:-0} )); then printf "%s\\n" "$1" >&"$outstream" fi } warn() { # Print a yellow line to the terminal, respecting _optverbose [ -z "$1" ] && return 1 local -i outstream=1 [ -n "$_optmachinemode" ] && outstream=2 if (( _optverbose >= ${2:-0} )); then if [ -t "$outstream" ]; then printf "\\e[33m%s\\e[0m\\n" "$1" >&"$outstream" else printf "WARN: %s\\n" "$1" >&"$outstream" fi fi } error() { # Print a red line to the terminal, exit if $2 is specified [ -z "$1" ] && return 1 if [ -t 2 ]; then printf "\\e[31m%s\\e[0m\\n" "$1" 1>&2 else printf "ERROR: %s\\n" "$1" 1>&2 fi [ -n "$2" ] && exit "${2:-1}" } has() { # Parse out all arguments and try to find them in path # If an argument cannot be found, set _return and fail for prog in "$@"; do if ! command -v "$prog" > /dev/null 2>&1; then _return="$prog" return 1 fi done return 0 } sanitize() { # Strip a line of special charactersj [ -z "$1" ] && return 1 _return="${1//[^ a-zA-Z0-9\[\]|()_-:]/}" } # More complex helper functions checksong() { # Check to see if a song exists in the cache [ -z "$1" ] && return 1 # Very rudimentary implementation # TODO: Make this a lot more thorough, maybe ensure MIME is good sanitize "$1" for file in "$_ptgdpmusicdir/$_return"*; do if [ -f "$file" ]; then _return="$file" return 0 fi done unset _return return 1 } cachesong() { # Check to see if a song exists in the cache, downloading if not # Sets _returnstatus to either "dl", "cache", or "err" # Sets _return to the song filename [ -z "$1" ] && return 1 sanitize "$1" filename="$_ptgdpmusicdir/$_return" if ! checksong "$_return"; then log "Downloading new song \"$1\"" 2 local ytdllog if ytdllog=$(youtube-dl \ --add-metadata \ --audio-format "best" \ --default-search "ytsearch" \ --playlist-items 1 \ "${_config[ytdl_args]}" \ -x \ -o "$filename.%(ext)s" \ "$1") then # Sanitize removes all periods, so this is safe for file in "$filename".*; do if [ -f "$file" ]; then _return="$file" else error "Downloaded song \"$_return\" but could not find output file" log "$ytdllog" 2 _returnstatus="err" return 1 fi done _returnstatus="dl" log "Downloaded song to \"$_return\"" 2 log "$ytdllog" 2 return 0 else log "$ytdllog" 2 _returnstatus="err" return 1 fi else _returnstatus="cache" return 0 fi } queuesong() { # Add a song to _queue, downloading if necessary # Sets _return to one of: dl, cache, err [ -z "$1" ] && return 1 cachesong "$1" || return 1 case $_returnstatus in cache) log "Using cached song \"$1\"" 1 log " $_return" 2 ;; *) log "Downloaded song \"$1\"" log " $_return" 2 ;; esac _queue+=("$_return") } validateline() { # Takes a line and errors if it's just whitespace or a comment local linenows=${1//[[:space:]]} if ! [ "${1#\#}" = "$1" ] || [ -z "$linenows" ]; then return 1 fi return 0 } # Backend-specific functions backend-validate() { # Ensure the backend is even proper # Returns 1 for missing dependencies # Everything after that is backend-specific. See below ( for backend in ${_supportedbackends[@]}; do if [ "$backend" = "${_config[backend]}" ]; then return 0 fi done return 1 ) || return $? # Backend-specific checks case ${_config[backend]} in audacious) has audacious audtool || return 1 ;; mpd) has mpd mpc || return 1 pgrep mpd > /dev/null 2>&1 || return 2 ;; esac return 0 } backend-enqueue() { # Enqueues a song # Note: mpd will assume you've updated the library since! [ -z "$1" ] && return 1 case ${_config[backend]} in audacious) audtool --playlist-addurl "$1" return 0 ;; mpd) file=${1##$_mpdroot/} if ! mpc add "$file" > /dev/null 2>&1; then errorval="$?" error "Could not add file: \"$file\" ($errorval)" fi return 0 ;; esac return 1 } backend-play() { # Plays a set up queue case ${_config[backend]} in audacious) audtool --playback-play ;; mpd) if ! mpc play > /dev/null 2>&1; then error "Could not play queue ($?)" return 1 fi return 0 ;; esac log "Started playback" 1 } backend-execqueue() { # Executes a queue, enqueueing files and autoplaying if configured case ${_config[backend]} in audacious) if ! pgrep audacious > /dev/null 2>&1; then warn "Audacious is not running; songs will be downloaded but not enqueued" fi ;; mpd) if ! mpc update --wait > /dev/null 2>&1; then error "Failed to update mpd library" 51 fi ;; esac for song in "${_queue[@]}"; do backend-enqueue "$song" done if [ "$_optautoplay" != "0" ]; then backend-play fi } # Core program functions printhelp() { cat << EOF Usage: $_name [OPTION]... [FILE]... Use youtube-dl and a music player to queue up or download a number of songs given plaintext files with only search queries -c [FILE] Load the given file in place of the usual config file -d Download songs but don't queue them up -h Print this help text -m Machine-processible mode. Output all log text to STDERR and print only the final list of songs to STDOUT, separated by newlines. -p Play the queue after it's built -v Print more status messages. Stacks Copyright (c) 2019 rehashedsalt@cock.li Licensed under the MIT license EOF } playlist() { [ -z "$1" ] && return 1 [ -e "$1" ] || error "Playlist \"$1\" does not exist" 60 [ -r "$1" ] || error "Playlist \"$1\" could not be read" 61 local -i dlcache=0 local -i dlsuccess=0 local -i dlerr=0 local -i goodlines=0 local -i totallines=0 while read line; do totallines+=1 if validateline "$line"; then goodlines+=1 queuesong "$line" || error "Failed to enqueue song: \"$line\"" case $_returnstatus in dl) dlsuccess+=1 ;; cache) dlcache+=1 ;; err) dlerr+=1 ;; esac else continue fi done < "$1" if [ -n "$_optmachinemode" ]; then for file in "${_queue[@]}"; do printf "$file\\n" done elif [ -z "$_optdlonly" ]; then backend-execqueue fi log "Finished: $dlcache cached, $dlsuccess downloaded, $dlerr failed" log "Playlist has $totallines total lines, $goodlines of them songs" 1 } # Main main() { # Getopts before anything else while getopts ":c:dhmpv" opt; do case $opt in c) _optconfigfile="$OPTARG" ;; d) _optdlonly=1 ;; h) _opthelp=1 ;; m) _optmachinemode=1 ;; p) _optautoplay=1 ;; v) _optverbose+=1 ;; :) error "Option requires argument: -$OPTARG" 2 ;; *) error "Invalid option: -$OPTARG" 2 ;; esac done # Parse out a config file if it exists if [ -f "$_optconfigfile" ]; then log "Loading config file" 2 while read line; do # If the line has an equals sign and isn't a comment if [ "$line" != "${line#*=}" ] && validateline "$line"; then local varname="${line%=*}" local value="${line#*=}" _config[$varname]="$value" log "Setting $varname to $value" 2 fi done < "$_optconfigfile" else warn "Could not find configuration file" 2 fi # Store arguments shift $((OPTIND - 1)) for arg in "$@"; do _args+=("$arg") done # Validate critical options ( for backend in ${_supportedbackends[@]}; do if [ "$backend" = "${_config[backend]}" ]; then return 0 fi done return 1 ) || error "Unsupported backend: ${_config[backend]}" 50 if [ -z "$_optmachinemode" ] && ! backend-validate; then errorcode=$? case $errorcode in 1) error "Missing dependency for backend ${_config[backend]}: $_return" 50 ;; *) error "Backend error: $errorcode: $_return" 50 ;; esac fi # Pre-really-do-stuff hooks like help text [ -n "$_opthelp" ] && printhelp && exit 0 # Do the do log "Validating dependencies" 2 if ! has youtube-dl pgrep; then error "Failed to find program: $_return" 1 fi if [ -n "${_args[*]}" ]; then # Files specified on the command line have priority for arg in "${_args[@]}"; do playlist "$arg" done elif ! [ -t 0 ]; then # If there are none of those, read from STDIN playlist "/dev/stdin" else warn "Nothing to do" exit 0 fi } main "$@"