Salt
4e5de57f02
This is a breaking change It's also actually the right way to do things and it comes with multiple playlist support
470 lines
12 KiB
Bash
Executable File
470 lines
12 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}"
|
|
declare -a _queue
|
|
declare -a _playlists
|
|
|
|
# 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
|
|
}
|
|
execplaylists() {
|
|
# Plays all playlists in _playlists
|
|
for list in "${_playlists[@]}"; do
|
|
playlist "$list" || error "Failed to play playlist: \"$list\""
|
|
done
|
|
if [ -n "$_optautoplay" ]; then
|
|
mpc play > /dev/null 2>&1
|
|
log "Started playback"
|
|
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]... [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 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.
|
|
-R <directory|file> 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
|
|
rofiplaysong "$_optrofi"
|
|
return $?
|
|
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
|
|
_playlists+=("$_optrofi"/"$choice".gdp)
|
|
execplaylists
|
|
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, unless he picked a file
|
|
if ! [ "$_optrofi" = "$1" ]; then
|
|
rofimenu
|
|
else
|
|
error "User aborted at selection" 62
|
|
fi
|
|
fi
|
|
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 "Parsed 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 ! [ "$dlfailure" = "0" ]; then
|
|
error "Some songs failed to download" 0
|
|
fi
|
|
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
|
|
|
|
# Get options
|
|
while getopts ":cdDf:pr:R:sh" opt; do
|
|
case $opt in
|
|
c)
|
|
clearcache
|
|
exit $?
|
|
;;
|
|
d)
|
|
_optdownloadonly=1
|
|
;;
|
|
D)
|
|
_optdryrun=1
|
|
;;
|
|
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
|
|
# Get program arguments (playlists)
|
|
shift $((OPTIND - 1))
|
|
for list in "$@"; do
|
|
if ! [ -f "$list" ]; then
|
|
error "Failed to find playlist: $list"
|
|
continue
|
|
fi
|
|
_playlists+=("$list")
|
|
done
|
|
[ -z "$_optdownloadonly" ] && [ -z "$_optdryrun" ] && ! validatedeps mpc && error "$_return is required outside of dry- and download-only runs"
|
|
[ -n "$_playlists" ] && [ -n "$_optrofi" ] && error "Flag -r cannot be used with playlist arguments" 2
|
|
[ -n "$_optdownloadonly" ] && [ -n "$_optautoplay" ] && error "Flags -d and -p conflict" 2
|
|
if [ -n "$_optrofi" ]; then rofimenu; exit $?; fi
|
|
if [ -n "$_playlists" ]; then
|
|
execplaylists
|
|
exit $?
|
|
else
|
|
error "Nothing to do" 1
|
|
fi
|
|
}
|
|
|
|
main "$@"
|
|
|