378 lines
		
	
	
		
			10 KiB
		
	
	
	
		
			Bash
		
	
	
		
			Executable File
		
	
	
	
	
			
		
		
	
	
			378 lines
		
	
	
		
			10 KiB
		
	
	
	
		
			Bash
		
	
	
		
			Executable File
		
	
	
	
	
| #! /bin/bash
 | |
| #
 | |
| # ptgdp - Play the Goddamned Playlist
 | |
| # Copyright (C) 2019 Vintage Salt <rehashedsalt@cock.li>
 | |
| #
 | |
| # 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}"
 | |
| 
 | |
| # 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
 | |
| }
 | |
| 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
 | |
| 	local linenows=${1//[[:space:]]}
 | |
| 	if ! [ "${1#\#}" = "$1" ] || [ -z "$linenows" ]; then
 | |
| 		return 1
 | |
| 	fi
 | |
| 	return 0
 | |
| }
 | |
| 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 <file>		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 <directory>	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.
 | |
| 
 | |
|   -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
 | |
| 	[ -d "$_optrofi" ] || error "Could not open directory \"$_optrofi\"" 2
 | |
| 	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
 | |
| 	choice="$(rofi -dmenu -i -p "$_name" <<< "$playlists" 2>/dev/null)"
 | |
| 	[ -z "$choice" ] && error "User aborted at selection" 62
 | |
| 	playlist "$_optrofi"/"$choice".gdp
 | |
| 	return 0
 | |
| }
 | |
| playlist() {
 | |
| 	[ -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
 | |
| 	[ -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
 | |
| 		# Strictly speaking, this removes all whitespace from the line
 | |
| 		# While not *exactly* what I'm looking for, it's sufficient for trimming whitespace lines
 | |
| 		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
 | |
| 		cachesong "$line"
 | |
| 		# What did we do?
 | |
| 		local errorcode=$?
 | |
| 		if [ -z "$_optdryrun" ]; then
 | |
| 			case $errorcode in
 | |
| 				0)
 | |
| 					# Downloaded
 | |
| 					log "Downloaded song \"$line\""
 | |
| 					dlsuccess+=1
 | |
| 					;;
 | |
| 				100)
 | |
| 					# Cached
 | |
| 					log "Using cached song \"$line\""
 | |
| 					dlexist+=1
 | |
| 					;;
 | |
| 				*)
 | |
| 					# Failed
 | |
| 					dlfailure+=1
 | |
| 					notify "Could not download song" \
 | |
| 						"youtube-dl did not download a song for \"$line\"" \
 | |
| 						normal dialog-error 3000
 | |
| 					error "Could not download song \"$line\""
 | |
| 					continue
 | |
| 					;;
 | |
| 			esac
 | |
| 			queue+=("$_return")
 | |
| 		else
 | |
| 			case $errorcode in
 | |
| 				0)
 | |
| 					# Success
 | |
| 					if [ -n "$_return" ]; then
 | |
| 						log "$line - \"$output\""
 | |
| 					else
 | |
| 						log "$line parsed, but title could not be extracted"
 | |
| 					fi
 | |
| 					dlsuccess+=1
 | |
| 					;;
 | |
| 				*)
 | |
| 					# Failure
 | |
| 					error "Could not find song \"$line\""
 | |
| 					dlerror+=1
 | |
| 					;;
 | |
| 			esac
 | |
| 		fi
 | |
| 	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"
 | |
| 			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
 | |
| 		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:sh" opt; do
 | |
| 		case $opt in
 | |
| 			c)
 | |
| 				clearcache
 | |
| 				exit $?
 | |
| 				;;
 | |
| 			d)
 | |
| 				_optdownloadonly=1
 | |
| 				;;
 | |
| 			D)
 | |
| 				_optdryrun=1
 | |
| 				;;
 | |
| 			f)
 | |
| 				_optfile="$OPTARG"
 | |
| 				;;
 | |
| 			p)
 | |
| 				_optautoplay=1
 | |
| 				;;
 | |
| 			r)
 | |
| 				_optrofi="$OPTARG"
 | |
| 				;;
 | |
| 			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 "$@"
 | |
| 
 |