From eeba67f6f69b2f59093179f704a0a9457ab1d012 Mon Sep 17 00:00:00 2001 From: Levon Gevorgyan Date: Sat, 25 Apr 2026 19:09:19 -0500 Subject: [PATCH] add caching to commands --- .gitignore | 3 +- add-synced-lyrics.sh | 400 +++++++------------------------------- lib/0-utils.sh | 261 +++++++++++++++++++++++++ lib/deezer.sh | 48 +++++ lib/lrclib.sh | 56 ++++++ lib/options.sh | 178 +++++++++++++++++ lib/spotify.sh | 71 +++++++ test-add-synced-lyrics.sh | 62 +++--- 8 files changed, 728 insertions(+), 351 deletions(-) create mode 100644 lib/0-utils.sh create mode 100644 lib/deezer.sh create mode 100644 lib/lrclib.sh create mode 100644 lib/options.sh create mode 100644 lib/spotify.sh diff --git a/.gitignore b/.gitignore index 116ecce..c29095a 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -test-dir* \ No newline at end of file +test-dir* +*.json \ No newline at end of file diff --git a/add-synced-lyrics.sh b/add-synced-lyrics.sh index 2ab3d1e..07dc70a 100755 --- a/add-synced-lyrics.sh +++ b/add-synced-lyrics.sh @@ -1,118 +1,10 @@ #!/usr/bin/env bash +# many functions are invoked via variables +# shellcheck disable=SC2329 + set -u -have_cmd() { - for cmd in "$@"; do - command -v "${cmd}" &>/dev/null || return 1 - done -} - -# shellcheck disable=SC2329 -void() { - echo "$@" &>/dev/null -} - -# shellcheck disable=SC2329 -dry() { - local args=("$@") - local numArgs="${#args[@]}" - test "${numArgs}" -eq 0 && return 0 - echo "'${args[0]}'" - local earlyStop=$((numArgs - 1)) - for ((i = 1; i < earlyStop; i++)); do - echo " '${args[${i}]}' \\" - done - echo " '${args[${i}]}'" -} - -check_required_utils() { - local utils=( - ffmpeg - jq - syncedlyrics - librelyrics - spotify # uv tool install --python 3.8 spotify-cli - ) - local missing=false - for util in "${utils[@]}"; do - if ! have_cmd "${util}"; then - echo "missing ${util}!" - missing=true - fi - done - - if [[ ${missing} == true ]]; then - return 1 - else - return 0 - fi -} - -# shellcheck disable=SC2329 -get_spotify_url() { - local search="$1" - local response artistAlbumTitle url - response="$( - spotify \ - search \ - --track "${search}" \ - --limit 1 \ - --raw - )" - - artistAlbumTitle="$( - jq -r '.items[0] | "\(.artists[0].name) - \(.album.name) - \(.name)"' \ - <<<"${response}" - )" - - if [[ "${artistAlbumTitle}" != "${search}" ]]; then - echo "${search} could not be found" - return 1 - fi - - url="$( - jq -r '.items[0] | "\(.external_urls.spotify)"' <<<"${response}" - )" - - if [[ -z "${url}" ]]; then - echo "could not determine url for ${search}" - return 1 - fi - - echo "${url}" -} - -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_artist_album_title() { - local file="$1" - local jqFilter='.streams[] | select(.codec_type == "audio") | .tags | "\(.ARTIST) - \(.ALBUM) - \(.TITLE)"' - ffprobe \ - -v error \ - -show_entries stream \ - -of json \ - "${file}" | jq \ - -r "${jqFilter}" -} - -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" -} - bash_basename() { local tmp path="$1" @@ -125,245 +17,99 @@ bash_basename() { printf '%s\n' "${tmp:-/}" } -readonly PROGNAME="$(bash_basename "$0")" +bash_dirname() { + # Usage: dirname "path" + local tmp=${1:-.} -usage() { - echo "${PROGNAME} [options] + [[ $tmp != *[!/]* ]] && { + printf '/\n' + return + } -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" - exit ${EXIT_CODE:-1} + tmp=${tmp%%"${tmp##*[!/]}"} + + [[ $tmp != */* ]] && { + printf '.\n' + return + } + + tmp=${tmp%/*} + tmp=${tmp%%"${tmp##*[!/]}"} + + printf '%s\n' "${tmp:-/}" } -set_default_options() { - DIR=${DIR:-} - FILE=${FILE:-} - RECURSE=${RECURSE:-false} - DRY_RUN=${DRY_RUN:-false} - IGNORE=${IGNORE:-false} - readonly MUSIC_EXTENSION_REGEX='.*\.(opus)$' -} +PROGPATH="$(readlink -f "$0")" +PROGDIR="$(bash_dirname "${PROGPATH}")" +PROGNAME="$(bash_basename "${PROGPATH}")" +# shellcheck disable=SC2034 +readonly PROGPATH PROGDIR PROGNAME || exit 1 -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 - ;; - -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=() - if [[ "${RECURSE}" == true ]]; then - local preGlobstar="$(shopt -p globstar)" - shopt -s globstar - for f in "${DIR}"/**/*; do - unsortedInputs+=("${f}") - done - ${preGlobstar} - else - for f in "${DIR}"/**; do - unsortedInputs+=("${f}") - done - fi - - 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 -} - -create_tmp_dir() { - TMPDIR="$(mktemp -d)" || return 1 - readonly TMPDIR - trap 'rm -rf ${TMPDIR}' EXIT -} +for lib in "${PROGDIR}/lib/"*; do + # shellcheck disable=SC1090 + source "${lib}" || exit 1 +done process_inputs() { - local dry='' void='' - if [[ "${DRY_RUN}" == true ]]; then - dry=dry - void=void - fi + local isrc output ret=0 + local downloadFuncs=( + download_with_lrclib + download_with_spotify + download_with_deezer + ) - local search='' output='' spotifyUrl='' ret='' for input in "${INPUTS[@]}"; do if [[ ${IGNORE} == false && ${ret} -ne 0 ]]; then return 1 fi - if [[ "${input,,}" == *'instrumental'* ]]; then + if [[ "${input,,}" == *'instrumental'* || + "${input,,}" == *'acoustic'* ]]; then echo "${input} is instrumental, skipping" continue fi + output='' output="$(set_lyric_file_name "${input}")" - if [[ ! -f "${output}" ]]; then - echo "processing ${input}" - search="$(get_artist_album_title "${input}")" - # try with syncedlyrics first - ${dry} syncedlyrics \ - --synced-only \ - --output "${output}" \ - "${search}" - - if ! ${void} test -f "${output}"; then - for f in "${TMPDIR}"/*; do - if [[ -f "${f}" ]]; then - echo "tempdir ${TMPDIR} is not empty!" - echo "aborting operation" - return 1 - fi - done - - # try with librelyrics-spotify next - if ! spotifyUrl="$(${dry} get_spotify_url "${search}")"; then - ret=1 - continue - fi - - if ! ${dry} librelyrics \ - --directory "${TMPDIR}" \ - "${spotifyUrl}"; then - ret=1 - continue - fi - - if ! ${dry} mv "${TMPDIR}"/*.lrc "${output}"; then - ret=1 - continue - fi - fi - - if ! ${void} test -f "${output}"; then - echo "failed to get lyrics for ${input}" - ret=1 + if [[ -f "${output}" ]]; then + if is_valid_lrc "${output}"; then continue + else + rm "${output}" || return 1 fi fi + + echo "processing ${input}" + isrc='' + if ! isrc="$(get_file_isrc "${input}")"; then + ret=1 + continue + fi + + local tmpOutput="${TMPDIR}/tmp.lrc" + for func in "${downloadFuncs[@]}"; do + "${func}" "${isrc}" "${tmpOutput}" && break + done + + if [[ -f "${tmpOutput}" ]]; then + if is_valid_lrc "${tmpOutput}"; then + mv "${tmpOutput}" "${output}" &>/dev/null || return 1 + else + rm "${tmpOutput}" || return 1 + fi + fi + + if ! void test -f "${output}"; then + echo "failed to get lyrics for ${input}" + ret=1 + continue + else + void echo "added lyrics for ${input}" + fi done + + return ${ret} } check_required_utils || exit 1 diff --git a/lib/0-utils.sh b/lib/0-utils.sh new file mode 100644 index 0000000..9318ded --- /dev/null +++ b/lib/0-utils.sh @@ -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} +} diff --git a/lib/deezer.sh b/lib/deezer.sh new file mode 100644 index 0000000..88cefb6 --- /dev/null +++ b/lib/deezer.sh @@ -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 +} diff --git a/lib/lrclib.sh b/lib/lrclib.sh new file mode 100644 index 0000000..890839c --- /dev/null +++ b/lib/lrclib.sh @@ -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}" +} diff --git a/lib/options.sh b/lib/options.sh new file mode 100644 index 0000000..dd62b79 --- /dev/null +++ b/lib/options.sh @@ -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 +} diff --git a/lib/spotify.sh b/lib/spotify.sh new file mode 100644 index 0000000..294507c --- /dev/null +++ b/lib/spotify.sh @@ -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 +} diff --git a/test-add-synced-lyrics.sh b/test-add-synced-lyrics.sh index 74c3572..0686054 100755 --- a/test-add-synced-lyrics.sh +++ b/test-add-synced-lyrics.sh @@ -7,8 +7,12 @@ set -u readonly THIS_DIR="$(dirname "$(readlink -f "$0")")" readonly TEST_DIR="${THIS_DIR}/test-dir" -readonly TEST_FILE_1="${TEST_DIR}/03 - Back Burner.opus" -readonly TEST_FILE_2="${TEST_DIR}/subdir/05 - Composure.opus" +# lyrics exist for this song +readonly GOOD_SONG="${TEST_DIR}/back burner.opus" +readonly GOOD_LYRICS="${GOOD_SONG//.opus/.lrc}" +# lyrics do not exist for this song +readonly BAD_SONG="${TEST_DIR}/subdir/avalanche.opus" +readonly BAD_LYRICS="${BAD_SONG//.opus/.lrc}" readonly PROG="${THIS_DIR}/add-synced-lyrics.sh" test_bad_input() { @@ -59,31 +63,42 @@ test_determine_inputs_recurse() { } test_file_dry() { - "${PROG}" --file "${TEST_FILE_1}" --dry-run || return 1 - - return 0 + "${PROG}" --file "${GOOD_SONG}" --dry-run } test_directory_dry() { - "${PROG}" --dir "${TEST_DIR}" --dry-run || return 1 - - return 0 + "${PROG}" --dir "${TEST_DIR}" --dry-run } test_directory_recurse_dry() { - "${PROG}" --dir "${TEST_DIR}" --recurse --dry-run || return 1 + "${PROG}" --dir "${TEST_DIR}" --recurse --dry-run +} + +test_good_file() { + "${PROG}" --file "${GOOD_SONG}" || return 1 + test -f "${GOOD_LYRICS}" +} + +test_bad_file() { + "${PROG}" --file "${BAD_SONG}" && return 1 + ! test -f "${BAD_LYRICS}" +} + +test_directory_recurse() { + setup_test_data || return 1 + # this test should fail since it includes the bad file + "${PROG}" --dir "${TEST_DIR}" --recurse && return 1 return 0 } -test_file() { - "${PROG}" --file "${TEST_FILE_1}" || return 1 - test -f "${TEST_FILE_1//.opus/.lrc}" -} +test_directory_recurse_ignore() { + setup_test_data || return 1 + "${PROG}" --dir "${TEST_DIR}" --recurse --ignore && return 1 + test -f "${GOOD_LYRICS}" || return 1 + test -f "${BAD_LYRICS}" && return 1 -test_directory_recurse() { - "${PROG}" --dir "${TEST_DIR}" --recurse || return 1 - test -f "${TEST_FILE_2//.opus/.lrc}" + return 0 } setup_test_data() { @@ -96,15 +111,13 @@ setup_test_data() { -i anullsrc=channel_layout=stereo:sample_rate=48000 -c:a libopus -t 5 - -metadata ALBUM="Messengers" - -metadata ARTIST="August Burns Red" ) "${ffmpegCmd[@]}" \ - -metadata TITLE="Back Burner" \ - "${TEST_FILE_1}" || return 1 + -metadata ISRC=USTN10700139 \ + "${GOOD_SONG}" || return 1 "${ffmpegCmd[@]}" \ - -metadata TITLE="Composure" \ - "${TEST_FILE_2}" || return 1 + -metadata ISRC=US5261822978 \ + "${BAD_SONG}" || return 1 } TESTS=( @@ -115,8 +128,10 @@ TESTS=( test_directory_dry test_directory_recurse_dry test_file_dry - test_file + test_good_file + test_bad_file test_directory_recurse + test_directory_recurse_ignore ) # make sure no test is accidentally missed @@ -135,6 +150,7 @@ for test in "${TESTS[@]}"; do "${test}" ) 2>&1 )"; then + echo echo "!! ${test} failed !!" echo "${testOutput}" exit 1