mirror of
https://github.com/levogevo/add-synced-lyrics.git
synced 2026-07-21 21:45:21 +00:00
add caching to commands
This commit is contained in:
+261
@@ -0,0 +1,261 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# many functions are invoked via variables
|
||||
# shellcheck disable=SC2329
|
||||
|
||||
# check commands exist
|
||||
# "$@" commands to check
|
||||
have_cmd() {
|
||||
for cmd in "$@"; do
|
||||
command -v "${cmd}" &>/dev/null || return 1
|
||||
done
|
||||
}
|
||||
|
||||
# validate results from `jq -r`
|
||||
jq_result_valid() {
|
||||
local result="$1"
|
||||
if [[ -z "${result}" || "${result}" == 'null' ]]; then
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
stderr() { echo "$*" 1>&2; }
|
||||
info() { stderr "INFO [${FUNCNAME[1]}]: $*"; }
|
||||
error() { stderr "ERROR [${FUNCNAME[1]}]: $*"; }
|
||||
|
||||
# only echo "$@" if ${DEBUG} is true
|
||||
debug() {
|
||||
if [[ ${DEBUG} == false ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
stderr "$@"
|
||||
}
|
||||
|
||||
# do not do anything
|
||||
# if ${DRY_RUN} is enabled
|
||||
void() {
|
||||
local cmd=("$@")
|
||||
if [[ ${DRY_RUN} == true ]]; then
|
||||
return 0
|
||||
else
|
||||
"${cmd[@]}"
|
||||
return $?
|
||||
fi
|
||||
}
|
||||
|
||||
# print out "$@" formatted for bash
|
||||
# if ${DRY_RUN} is enabled
|
||||
# otherwise, execute the actual command
|
||||
dry() {
|
||||
local cmd=("$@")
|
||||
if [[ ${DRY_RUN} == true ]]; then
|
||||
local numCmd="${#cmd[@]}"
|
||||
test "${numCmd}" -eq 0 && return 0
|
||||
local firstLine="'${cmd[0]}'"
|
||||
test "${numCmd}" -gt 1 && firstLine+=' \'
|
||||
stderr "${firstLine}"
|
||||
local earlyStop=$((numCmd - 1))
|
||||
for ((i = 1; i < earlyStop; i++)); do
|
||||
stderr " '${cmd[${i}]}' \\"
|
||||
done
|
||||
stderr " '${cmd[${i}]}'"
|
||||
else
|
||||
"${cmd[@]}"
|
||||
return $?
|
||||
fi
|
||||
}
|
||||
|
||||
# check required utilities for this project
|
||||
check_required_utils() {
|
||||
local utils=(
|
||||
base64
|
||||
cat
|
||||
ffprobe
|
||||
jq
|
||||
librelyrics
|
||||
spotify # uv tool install --python 3.8 spotify-cli
|
||||
)
|
||||
local missing=false
|
||||
for util in "${utils[@]}"; do
|
||||
if ! have_cmd "${util}"; then
|
||||
error "missing ${util}!"
|
||||
missing=true
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ ${missing} == true ]]; then
|
||||
return 1
|
||||
else
|
||||
return 0
|
||||
fi
|
||||
}
|
||||
|
||||
cache_command() {
|
||||
local cmd=("$@")
|
||||
local hash cacheFile cmdOut contents ret
|
||||
hash="$(sha256sum <<<"${cmd[*]}")" || return 1
|
||||
read -r cacheFile _ <<<"${hash}"
|
||||
cacheFilePath="${CACHE_DIR}/${cacheFile}"
|
||||
if [[ -f "${cacheFilePath}" && ${DRY_RUN} == false ]]; then
|
||||
mapfile -t contents <"${cacheFilePath}"
|
||||
ret=$?
|
||||
# skip first line since it is the command
|
||||
printf '%s\n' "${contents[@]:1}"
|
||||
else
|
||||
cmdOut="$(dry "${cmd[@]}")"
|
||||
ret=$?
|
||||
if [[ ${ret} -eq 0 && ${DRY_RUN} == false ]]; then
|
||||
{
|
||||
echo "${cmd[*]}"
|
||||
echo "${cmdOut}"
|
||||
} >"${cacheFilePath}" || return 1
|
||||
echo "${cmdOut}"
|
||||
fi
|
||||
fi
|
||||
|
||||
return "${ret}"
|
||||
}
|
||||
|
||||
create_tmp_dir() {
|
||||
TMPDIR="$(mktemp -d)" || return 1
|
||||
readonly TMPDIR
|
||||
trap 'rm -rf ${TMPDIR}' EXIT
|
||||
}
|
||||
|
||||
check_tmp_dir_empty() {
|
||||
for f in "${TMPDIR}"/*; do
|
||||
if [[ -f "${f}" ]]; then
|
||||
error "tempdir ${TMPDIR} is not empty!"
|
||||
error "aborting operation"
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# librelyrics only exposes a directory to download to
|
||||
# so this wrapper function downloads to a directory
|
||||
# and then outputs the contents of the downloaded file
|
||||
# to stdout so it can effectively be cached
|
||||
librelyrics_dl() {
|
||||
local url="$1"
|
||||
check_tmp_dir_empty || return 1
|
||||
librelyrics \
|
||||
--directory "${TMPDIR}" \
|
||||
"${url}" 1>&2
|
||||
local librelyricsRet=$?
|
||||
|
||||
local downloadedFile=''
|
||||
for f in "${TMPDIR}/"*; do
|
||||
if [[ -f "${f}" ]]; then
|
||||
downloadedFile="${f}"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ ${librelyricsRet} -eq 0 && -z ${downloadedFile} ]]; then
|
||||
error "failed to download file for ${url} despite no librelyrics error"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local fileContents=''
|
||||
if [[ -n "${downloadedFile}" ]]; then
|
||||
fileContents="$(<"${downloadedFile}")"
|
||||
rm "${downloadedFile}" || return 1
|
||||
fi
|
||||
|
||||
if [[ ${librelyricsRet} -eq 0 && -z ${fileContents} ]]; then
|
||||
error "failed to read file for ${url} despite no librelyrics error"
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "${fileContents}"
|
||||
}
|
||||
|
||||
download_with_librelyrics() {
|
||||
local isrc="$1"
|
||||
local output="$2"
|
||||
local urlType="$3"
|
||||
|
||||
if [[ ! "${urlType}" =~ spotify|deezer ]]; then
|
||||
error "only spotify|deezer are supported"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local url=''
|
||||
url="$(get_"${urlType}"_url "${isrc}")" || return 1
|
||||
|
||||
local lyrics=''
|
||||
lyrics="$(cache_command \
|
||||
librelyrics_dl \
|
||||
"${url}")"
|
||||
|
||||
if [[ -z "${lyrics}" ]]; then
|
||||
error "could not get lyrics for ${isrc} using ${urlType}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
info "found lyrics for ${isrc} with ${urlType}"
|
||||
echo "${lyrics}" >"${output}"
|
||||
}
|
||||
|
||||
output_eval_functions() {
|
||||
while read -r line; do
|
||||
IFS=' ' read -r _ _ function <<<"${line}"
|
||||
[[ "${function}" == '_determine_inputs_'* ||
|
||||
"${function}" == set_default_options ]] || continue
|
||||
${line}
|
||||
done <<<"$(declare -F)"
|
||||
}
|
||||
|
||||
get_file_isrc() {
|
||||
local file="$1"
|
||||
local probe='' jqResult=''
|
||||
|
||||
local probe=''
|
||||
probe="$(
|
||||
ffprobe \
|
||||
-v error \
|
||||
-show_entries stream \
|
||||
-of json \
|
||||
"${file}"
|
||||
)" || return 1
|
||||
|
||||
local jqFilter='.streams[] | select(.codec_type == "audio") | .tags | with_entries(.key |= ascii_downcase) | .isrc'
|
||||
jqResult="$(jq -r "${jqFilter}" <<<"${probe}")"
|
||||
|
||||
if [[ "${jqResult}" == 'null' ]]; then
|
||||
error "failed to find ISRC for ${file}"
|
||||
return 1
|
||||
fi
|
||||
echo "${jqResult}"
|
||||
}
|
||||
|
||||
set_lyric_file_name() {
|
||||
local file="$1"
|
||||
if [[ ! "${file}" =~ ${MUSIC_EXTENSION_REGEX} ]]; then
|
||||
echo "${file} is not a music file"
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "${file%.opus}.lrc"
|
||||
}
|
||||
|
||||
is_valid_lrc() {
|
||||
local file="$1"
|
||||
|
||||
test -f "${file}" || return 1
|
||||
|
||||
local valid=0
|
||||
while read -r line; do
|
||||
if [[ "${line}" != '['* ]]; then
|
||||
valid=1
|
||||
break
|
||||
fi
|
||||
done <"${file}"
|
||||
|
||||
return ${valid}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# many functions are invoked via variables
|
||||
# shellcheck disable=SC2329
|
||||
|
||||
get_deezer_isrc_response() {
|
||||
local isrc="$1"
|
||||
if [[ -z "${isrc}" ]]; then
|
||||
error "missing isrc"
|
||||
return 1
|
||||
fi
|
||||
|
||||
cache_command \
|
||||
curl \
|
||||
--silent \
|
||||
--retry 3 \
|
||||
"https://api.deezer.com/track/isrc:${isrc}"
|
||||
}
|
||||
|
||||
get_deezer_url() {
|
||||
local isrc="$1"
|
||||
if [[ -z "${isrc}" ]]; then
|
||||
error "missing isrc"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local deezerResponse=''
|
||||
deezerResponse="$(get_deezer_isrc_response "${isrc}")" || return 1
|
||||
|
||||
local jqResult=''
|
||||
jqResult="$(jq -r .link <<<"${deezerResponse}")"
|
||||
|
||||
if ! jq_result_valid "${jqResult}"; then
|
||||
error "could not get link from deezer for ${isrc}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "${jqResult}"
|
||||
}
|
||||
|
||||
download_with_deezer() {
|
||||
local isrc="$1"
|
||||
local output="$2"
|
||||
download_with_librelyrics \
|
||||
"${isrc}" \
|
||||
"${output}" \
|
||||
deezer
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# many functions are invoked via variables
|
||||
# shellcheck disable=SC2329
|
||||
|
||||
get_lrclib_api_response() {
|
||||
local isrc="$1"
|
||||
local deezerResponse=''
|
||||
deezerResponse="$(get_deezer_isrc_response "${isrc}")" || return 1
|
||||
|
||||
# get metadata from deezer
|
||||
local deezerMetadata=(
|
||||
"track .title"
|
||||
"artist .artist.name"
|
||||
"album .album.title"
|
||||
"duration .duration"
|
||||
)
|
||||
for data in "${deezerMetadata[@]}"; do
|
||||
IFS=' ' read -r env jqFilter <<<"${data}"
|
||||
jqResult="$(jq -r "${jqFilter}" <<<"${deezerResponse}")"
|
||||
if ! jq_result_valid "${jqResult}"; then
|
||||
error "could not determine lrclib ${env} for ${isrc}"
|
||||
return 1
|
||||
fi
|
||||
declare "${env}=${jqResult}"
|
||||
debug "${env}=${jqResult}"
|
||||
done
|
||||
|
||||
# variables assigned above
|
||||
# shellcheck disable=SC2154
|
||||
cache_command \
|
||||
curl \
|
||||
"https://lrclib.net/api/get" \
|
||||
--silent \
|
||||
--get \
|
||||
--data-urlencode "track_name=${track}" \
|
||||
--data-urlencode "artist_name=${artist}" \
|
||||
--data-urlencode "album_name=${album}" \
|
||||
--data-urlencode "duration=${duration}"
|
||||
}
|
||||
|
||||
download_with_lrclib() {
|
||||
local isrc="$1"
|
||||
local output="$2"
|
||||
|
||||
# get lrclib ID
|
||||
local lrclibResponse syncedLyrics
|
||||
lrclibResponse="$(get_lrclib_api_response "${isrc}")"
|
||||
syncedLyrics="$(jq -r .syncedLyrics <<<"${lrclibResponse}")"
|
||||
if ! jq_result_valid "${syncedLyrics}"; then
|
||||
error "could not get lyrics for ${isrc}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "${syncedLyrics}" >"${output}"
|
||||
}
|
||||
+178
@@ -0,0 +1,178 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# many functions are invoked via variables
|
||||
# shellcheck disable=SC2329
|
||||
|
||||
usage() {
|
||||
echo "${PROGNAME} [options]
|
||||
|
||||
OPTIONS:
|
||||
-d, --dir process files in directory
|
||||
-r, --recurse recursively process directory
|
||||
-f, --file process one file
|
||||
-n, --dry-run do not actually add synced lyrics,
|
||||
just show the files that would be processed
|
||||
-e, --eval output bash functions that can be directly processed
|
||||
by eval for use with testing this script.
|
||||
-i, --ignore ignore errors, and continue attempting to find lyrics
|
||||
even in the case of errors
|
||||
-h, --help show this output
|
||||
--debug extra information useful for debugging"
|
||||
exit "${EXIT_CODE:-1}"
|
||||
}
|
||||
|
||||
set_default_options() {
|
||||
DIR=${DIR:-}
|
||||
FILE=${FILE:-}
|
||||
RECURSE=${RECURSE:-false}
|
||||
DRY_RUN=${DRY_RUN:-false}
|
||||
IGNORE=${IGNORE:-false}
|
||||
DEBUG=${DEBUG:-false}
|
||||
|
||||
# cache
|
||||
CACHE_HOME="${XDG_CACHE_HOME:-"${HOME}/.cache"}"
|
||||
CACHE_DIR="${CACHE_HOME}/add-synced-lyrics"
|
||||
if [[ ! -d "${CACHE_DIR}" ]]; then
|
||||
mkdir "${CACHE_DIR}" || return 1
|
||||
fi
|
||||
|
||||
readonly MUSIC_EXTENSION_REGEX='.*\.(opus)$'
|
||||
}
|
||||
|
||||
process_options() {
|
||||
while [[ $# -gt 0 ]]; do
|
||||
option="$1"
|
||||
value="${2:-}"
|
||||
case "${option}" in
|
||||
-d | --dir)
|
||||
if [[ ! -d "${value}" ]]; then
|
||||
echo "directory ${value} does not exist"
|
||||
usage
|
||||
fi
|
||||
readonly DIR="${value}"
|
||||
shift 2
|
||||
;;
|
||||
-r | --recurse)
|
||||
readonly RECURSE=true
|
||||
shift 1
|
||||
;;
|
||||
-n | --dry-run)
|
||||
readonly DRY_RUN=true
|
||||
shift 1
|
||||
;;
|
||||
-i | --ignore)
|
||||
readonly IGNORE=true
|
||||
shift 1
|
||||
;;
|
||||
--debug)
|
||||
readonly DEBUG=true
|
||||
shift 1
|
||||
;;
|
||||
-e | --eval)
|
||||
output_eval_functions
|
||||
exit 0
|
||||
;;
|
||||
-f | --file)
|
||||
if [[ ! -f "${value}" ]]; then
|
||||
echo "file ${value} does not exist"
|
||||
usage
|
||||
fi
|
||||
readonly FILE="${value}"
|
||||
shift 2
|
||||
;;
|
||||
-h | --help)
|
||||
EXIT_CODE=0 usage
|
||||
;;
|
||||
*)
|
||||
echo "bad option"
|
||||
usage
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ "${DIR:-}${FILE:-}" == '' ]]; then
|
||||
echo "either --dir or --file must be used"
|
||||
usage
|
||||
fi
|
||||
|
||||
if [[ -n "${DIR}" && -n "${FILE}" ]]; then
|
||||
echo "cannot process both directory and file"
|
||||
usage
|
||||
fi
|
||||
|
||||
if [[ "${RECURSE}" == true && -n "${FILE}" ]]; then
|
||||
echo "--recurse can only be used with --dir"
|
||||
usage
|
||||
fi
|
||||
}
|
||||
|
||||
_determine_inputs_fd() {
|
||||
local fdCmd=(
|
||||
fd
|
||||
--type f
|
||||
"${MUSIC_EXTENSION_REGEX}"
|
||||
"${DIR}"
|
||||
)
|
||||
|
||||
if [[ "${RECURSE}" == false ]]; then
|
||||
fdCmd+=(--max-depth 1)
|
||||
fi
|
||||
|
||||
mapfile -t INPUTS < <("${fdCmd[@]}" | sort)
|
||||
}
|
||||
|
||||
_determine_inputs_find() {
|
||||
local findCmd=(
|
||||
find
|
||||
"${DIR}"
|
||||
)
|
||||
|
||||
if [[ "${RECURSE}" == false ]]; then
|
||||
findCmd+=(-maxdepth 1)
|
||||
fi
|
||||
|
||||
findCmd+=(
|
||||
-regextype posix-extended
|
||||
-regex "${MUSIC_EXTENSION_REGEX}"
|
||||
-type f
|
||||
)
|
||||
|
||||
mapfile -t INPUTS < <("${findCmd[@]}" | sort)
|
||||
}
|
||||
|
||||
_determine_inputs_bash() {
|
||||
local unsortedInputs=()
|
||||
local pattern="${DIR}/*"
|
||||
shopt -s nullglob globstar || return 1
|
||||
|
||||
[[ "${RECURSE}" == true ]] && pattern="${DIR}/**/*"
|
||||
mapfile -t unsortedInputs < <(printf '%s\n' ${pattern})
|
||||
|
||||
local filteredInputs=()
|
||||
for input in "${unsortedInputs[@]}"; do
|
||||
[[ "${input}" =~ ${MUSIC_EXTENSION_REGEX} ]] || continue
|
||||
filteredInputs+=("${input}")
|
||||
done
|
||||
|
||||
mapfile -t INPUTS < <(printf '%s\n' "${filteredInputs[@]}" | sort)
|
||||
}
|
||||
|
||||
determine_inputs() {
|
||||
INPUTS=()
|
||||
if [[ -n "${FILE}" ]]; then
|
||||
INPUTS=("${FILE}")
|
||||
elif have_cmd fd; then
|
||||
_determine_inputs_fd || return 1
|
||||
elif have_cmd find; then
|
||||
_determine_inputs_find || return 1
|
||||
else
|
||||
_determine_inputs_bash || return 1
|
||||
fi
|
||||
|
||||
if [[ "${#INPUTS[@]}" -eq 0 ]]; then
|
||||
echo "no inputs found, something is wrong"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
readonly INPUTS
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# many functions are invoked via variables
|
||||
# shellcheck disable=SC2329
|
||||
|
||||
get_spotify_url() {
|
||||
local isrc="$1"
|
||||
if [[ -z "${isrc}" ]]; then
|
||||
error "missing isrc"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local search="isrc:${isrc}"
|
||||
local response
|
||||
response="$(
|
||||
cache_command \
|
||||
spotify \
|
||||
search \
|
||||
--track \
|
||||
"${search}" \
|
||||
--limit 1 \
|
||||
--raw
|
||||
)" || return 1
|
||||
|
||||
local tags=(
|
||||
spotifyISRC
|
||||
spotifyURL
|
||||
)
|
||||
# unset tag values
|
||||
for tag in "${tags[@]}"; do
|
||||
declare "${tag}="
|
||||
done
|
||||
|
||||
local jqFilter='.items[] | "\(.external_ids.isrc)|\(.external_urls.spotify)"'
|
||||
local jqOutput="$(jq -r "${jqFilter}" <<<"${response}")"
|
||||
|
||||
# compare tags versus the reference for each line
|
||||
local foundMatching=false
|
||||
while read -r line; do
|
||||
IFS='|' read -r "${tags[@]}" <<<"${line}"
|
||||
|
||||
for tag in "${tags[@]}"; do
|
||||
declare -n spotifyTagValue="${tag}"
|
||||
debug "${tag}=${spotifyTagValue}"
|
||||
done
|
||||
|
||||
# shellcheck disable=SC2154
|
||||
if [[ "${spotifyISRC,,}" == "${isrc,,}" ]]; then
|
||||
foundMatching=true
|
||||
break
|
||||
fi
|
||||
done <<<"${jqOutput}"
|
||||
|
||||
if [[ "${foundMatching}" == false ]]; then
|
||||
error "could not find matching spotify track for ${isrc}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# shellcheck disable=SC2154
|
||||
echo "${spotifyURL}"
|
||||
return 0
|
||||
}
|
||||
|
||||
download_with_spotify() {
|
||||
local isrc="$1"
|
||||
local output="$2"
|
||||
download_with_librelyrics \
|
||||
"${isrc}" \
|
||||
"${output}" \
|
||||
spotify
|
||||
}
|
||||
Reference in New Issue
Block a user