Compare commits

...
7 Commits
Author SHA1 Message Date
levogevo dc27d4493c init readme 2026-05-06 16:16:27 -05:00
levogevo 3427522cb6 increase coverage 2026-05-05 18:38:07 -05:00
levogevo c0c7364e4a add lyricsify 2026-05-05 18:03:25 -05:00
levogevo ffb5cafaf3 add --embed 2026-04-29 17:42:01 -05:00
levogevo 5ecfd07ad2 increase coverage 2026-04-29 14:58:27 -05:00
levogevo 3fec74f772 add coverage 2026-04-28 18:38:20 -05:00
levogevo 5b25505990 switch to dedicated echo_ functions 2026-04-28 09:36:27 -05:00
10 changed files with 750 additions and 133 deletions
+1
View File
@@ -1,2 +1,3 @@
test-dir* test-dir*
*.json *.json
coverage*
+35
View File
@@ -0,0 +1,35 @@
# add-synced-lyrics
A script that automates adding lyrics to your songs.
```
add-synced-lyrics.sh [options]
OPTIONS:
-d, --dir process files in directory
-r, --recurse recursively process directory
-f, --file process one file
-e, --embed embed lyrics directly into the song file
-n, --dry-run do not add synced lyrics,
just show the files that would be processed
-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
--eval output bash functions that can be directly processed
by eval for use with testing this script.
```
# Requirements
- CLI tools:
- coreutils: `mktemp cat sort base64 readlink`
- `ffmpeg ffprobe`
- `jq`
- `node` (any version)
- [`librelyrics`](https://github.com/libre-lyrics/librelyrics):
- uses the [deezer](https://github.com/libre-lyrics/librelyrics-deezer) and [spotify](https://github.com/libre-lyrics/librelyrics-spotify) plugins
- [`spotify`](https://github.com/ledesmablt/spotify-cli) most easily installed with `uv tool install --python 3.8 spotify-cli`
- Music that has the appropriate ISRC tag.
## Features
- Uses the ISRC tag to search for synced lyrics across the download providers: lrclib, spotify, and deezer (in that order).
- Caches all networked-calls to the download providers.
- Supports separate (dedicated .lrc file) or adding the lyrics directly to the song file with the LYRICS tag.
+40 -27
View File
@@ -39,9 +39,9 @@ bash_dirname() {
printf '%s\n' "${tmp:-/}" printf '%s\n' "${tmp:-/}"
} }
PROGPATH="$(readlink -f "$0")" PROGPATH="$(readlink -f "$0")" || exit 1
PROGDIR="$(bash_dirname "${PROGPATH}")" PROGDIR="$(bash_dirname "${PROGPATH}")" || exit 1
PROGNAME="$(bash_basename "${PROGPATH}")" PROGNAME="$(bash_basename "${PROGPATH}")" || exit 1
# shellcheck disable=SC2034 # shellcheck disable=SC2034
readonly PROGPATH PROGDIR PROGNAME || exit 1 readonly PROGPATH PROGDIR PROGNAME || exit 1
@@ -56,57 +56,70 @@ process_inputs() {
download_with_lrclib download_with_lrclib
download_with_spotify download_with_spotify
download_with_deezer download_with_deezer
download_with_lyricsify
) )
for input in "${INPUTS[@]}"; do local numInputs="${#INPUTS[@]}"
for ((i = 0; i < numInputs; i++)); do
if [[ ${IGNORE} == false && ${ret} -ne 0 ]]; then if [[ ${IGNORE} == false && ${ret} -ne 0 ]]; then
return 1 return 1
fi fi
local input="${INPUTS[${i}]}"
echo_info "processing $((i + 1))/${numInputs}: ${input}"
if [[ "${input,,}" == *'instrumental'* || if [[ "${input,,}" == *'instrumental'* ||
"${input,,}" == *'acoustic'* ]]; then "${input,,}" == *'acoustic'* ]]; then
echo "${input} is instrumental, skipping" echo_pass "${input} is instrumental, skipping"
continue continue
fi fi
output='' output=''
output="$(set_lyric_file_name "${input}")" if ! output="$(set_lyric_file_name "${input}")"; then
echo_fail "could not determine lyric file name for ${input}"
ret=1
continue
fi
if [[ -f "${output}" ]]; then if validate_song_lyrics "${input}" "${output}"; then
if is_valid_lrc "${output}"; then echo_pass "${input} already has lyrics, skipping"
continue continue
else
rm "${output}" || return 1
fi
fi fi
echo "processing ${input}"
isrc='' isrc=''
if ! isrc="$(get_file_isrc "${input}")"; then if ! isrc="$(get_file_isrc "${input}")"; then
ret=1 ret=1
continue continue
fi fi
local tmpOutput="${TMPDIR}/tmp.lrc" local tmpOutput="${TMPDIR}/$(bash_basename "${output}")"
for func in "${downloadFuncs[@]}"; do for func in "${downloadFuncs[@]}"; do
"${func}" "${isrc}" "${tmpOutput}" && break # try to download
if ! "${func}" "${isrc}" "${tmpOutput}"; then
continue
fi
# validate
if validate_lrc "${tmpOutput}"; then
# passed download and validation, stop processing
break
fi
done done
# skip processing since no files should be modified
[[ ${DRY_RUN} == true ]] && continue
if [[ -f "${tmpOutput}" ]]; then if [[ -f "${tmpOutput}" ]]; then
if is_valid_lrc "${tmpOutput}"; then add_lyrics_to_song \
mv "${tmpOutput}" "${output}" &>/dev/null || return 1 "${input}" \
else "${tmpOutput}" \
rm "${tmpOutput}" || return 1 "${output}" &&
fi echo_pass "added lyrics for ${input}"
else
echo_fail "could not find lyrics for ${input}"
ret=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 done
return ${ret} return ${ret}
+195 -33
View File
@@ -20,29 +20,48 @@ jq_result_valid() {
return 0 return 0
} }
stderr() { echo "$*" 1>&2; } # ANSI colors
info() { stderr "INFO [${FUNCNAME[1]}]: $*"; } readonly RED='\e[0;31m'
error() { stderr "ERROR [${FUNCNAME[1]}]: $*"; } readonly GREEN='\e[0;32m'
readonly PURPLE='\e[0;35m'
readonly YELLOW='\e[0;33m'
readonly CYAN='\e[0;36m'
readonly NC='\e[0m'
# only echo "$@" if ${DEBUG} is true stderr() {
local word="${WORD:-}"
local color="${COLOR:-}"
local function="${FUNCNAME[2]}"
if [[ "${function}" =~ debug|void ]]; then
function="${FUNCNAME[3]}"
fi
echo \
-e \
"${color}${word}${NC}" \
"[${function}]" \
"$@" 1>&2
}
echo_info() { WORD=INFO COLOR="${CYAN}" stderr "$@"; }
echo_fail() { WORD=FAIL COLOR="${RED}" stderr "$@"; }
echo_pass() { WORD=PASS COLOR="${GREEN}" stderr "$@"; }
echo_debug() { WORD=DBUG COLOR="${PURPLE}" stderr "$@"; }
# only perform the command given if ${DEBUG} is enabled
debug() { debug() {
if [[ ${DEBUG} == false ]]; then if [[ ${DEBUG} == false ]]; then
return 0 return 0
fi fi
stderr "$@" "$@"
} }
# do not do anything # only perform the command given if ${DRY_RUN} is disabled
# if ${DRY_RUN} is enabled
void() { void() {
local cmd=("$@")
if [[ ${DRY_RUN} == true ]]; then if [[ ${DRY_RUN} == true ]]; then
return 0 return 0
else
"${cmd[@]}"
return $?
fi fi
"$@"
} }
# print out "$@" formatted for bash # print out "$@" formatted for bash
@@ -70,17 +89,33 @@ dry() {
# check required utilities for this project # check required utilities for this project
check_required_utils() { check_required_utils() {
local utils=( local utils=(
base64 # coreutils
bash
cat cat
ffprobe sort
base64
mktemp
readlink
jq jq
node
docker
ffmpeg
ffprobe
librelyrics librelyrics
spotify # uv tool install --python 3.8 spotify-cli spotify
) )
if [[ "${1:-}" == '--print-required' ]]; then
echo "utils='${utils[*]}'"
return
fi
local missing=false local missing=false
for util in "${utils[@]}"; do for util in "${utils[@]}"; do
if ! have_cmd "${util}"; then if ! have_cmd "${util}"; then
error "missing ${util}!" echo_fail "missing ${util}!"
missing=true missing=true
fi fi
done done
@@ -92,17 +127,33 @@ check_required_utils() {
fi fi
} }
echo_if_fail() {
local cmd=("$@")
local output
output="$("${cmd[@]}" 2>&1)"
local ret=$?
if [[ ${ret} -ne 0 ]]; then
echo_fail "${cmd[*]}"
echo_fail "${output}"
fi
return ${ret}
}
cache_command() { cache_command() {
local cmd=("$@") local cmd=("$@")
local hash cacheFile cmdOut contents ret local hash cacheFile cmdOut contents ret
hash="$(sha256sum <<<"${cmd[*]}")" || return 1 hash="$(sha256sum <<<"${cmd[*]}")" || return 1
read -r cacheFile _ <<<"${hash}" read -r cacheFile _ <<<"${hash}"
cacheFilePath="${CACHE_DIR}/${cacheFile}" cacheFilePath="${CACHE_DIR}/${cacheFile}"
if [[ -f "${cacheFilePath}" && ${DRY_RUN} == false ]]; then if [[ -f "${cacheFilePath}" ]]; then
mapfile -t cmdArr <<<"${cmd[*]}"
local skipLines="${#cmdArr[@]}"
mapfile -t contents <"${cacheFilePath}" mapfile -t contents <"${cacheFilePath}"
ret=$? ret=$?
# skip first line since it is the command # skip N lines respective to the command length
printf '%s\n' "${contents[@]:1}" printf '%s\n' "${contents[@]:${skipLines}}"
else else
cmdOut="$(dry "${cmd[@]}")" cmdOut="$(dry "${cmd[@]}")"
ret=$? ret=$?
@@ -119,6 +170,8 @@ cache_command() {
} }
create_tmp_dir() { create_tmp_dir() {
[[ -n "${TMPDIR:-}" ]] && return
TMPDIR="$(mktemp -d)" || return 1 TMPDIR="$(mktemp -d)" || return 1
readonly TMPDIR readonly TMPDIR
trap 'rm -rf ${TMPDIR}' EXIT trap 'rm -rf ${TMPDIR}' EXIT
@@ -127,9 +180,11 @@ create_tmp_dir() {
check_tmp_dir_empty() { check_tmp_dir_empty() {
for f in "${TMPDIR}"/*; do for f in "${TMPDIR}"/*; do
if [[ -f "${f}" ]]; then if [[ -f "${f}" ]]; then
error "tempdir ${TMPDIR} is not empty!" echo_fail "tempdir ${TMPDIR} is not empty!"
error "aborting operation" echo_fail "contains ${f}"
return 1 echo_fail "aborting operation"
# execution becomes undefined if TMPDIR is not empty
exit 1
fi fi
done done
@@ -157,7 +212,7 @@ librelyrics_dl() {
done done
if [[ ${librelyricsRet} -eq 0 && -z ${downloadedFile} ]]; then if [[ ${librelyricsRet} -eq 0 && -z ${downloadedFile} ]]; then
error "failed to download file for ${url} despite no librelyrics error" echo_fail "failed to download file for ${url} despite no librelyrics error"
return 1 return 1
fi fi
@@ -168,7 +223,7 @@ librelyrics_dl() {
fi fi
if [[ ${librelyricsRet} -eq 0 && -z ${fileContents} ]]; then if [[ ${librelyricsRet} -eq 0 && -z ${fileContents} ]]; then
error "failed to read file for ${url} despite no librelyrics error" echo_fail "failed to read file for ${url} despite no librelyrics error"
return 1 return 1
fi fi
@@ -181,7 +236,7 @@ download_with_librelyrics() {
local urlType="$3" local urlType="$3"
if [[ ! "${urlType}" =~ spotify|deezer ]]; then if [[ ! "${urlType}" =~ spotify|deezer ]]; then
error "only spotify|deezer are supported" echo_fail "only spotify|deezer are supported"
return 1 return 1
fi fi
@@ -194,11 +249,11 @@ download_with_librelyrics() {
"${url}")" "${url}")"
if [[ -z "${lyrics}" ]]; then if [[ -z "${lyrics}" ]]; then
error "could not get lyrics for ${isrc} using ${urlType}" debug echo_fail "could not find lyrics for ${isrc} using ${urlType}"
return 1 return 1
fi fi
info "found lyrics for ${isrc} with ${urlType}" debug echo_info "found lyrics for ${isrc} with ${urlType}"
echo "${lyrics}" >"${output}" echo "${lyrics}" >"${output}"
} }
@@ -209,6 +264,7 @@ output_eval_functions() {
"${function}" == set_default_options ]] || continue "${function}" == set_default_options ]] || continue
${line} ${line}
done <<<"$(declare -F)" done <<<"$(declare -F)"
check_required_utils --print-required
} }
get_file_isrc() { get_file_isrc() {
@@ -228,34 +284,140 @@ get_file_isrc() {
jqResult="$(jq -r "${jqFilter}" <<<"${probe}")" jqResult="$(jq -r "${jqFilter}" <<<"${probe}")"
if [[ "${jqResult}" == 'null' ]]; then if [[ "${jqResult}" == 'null' ]]; then
error "failed to find ISRC for ${file}" echo_fail "could not find ISRC for ${file}"
return 1 return 1
fi fi
echo "${jqResult}" echo "${jqResult}"
} }
get_file_lyrics() {
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) | .lyrics'
jqResult="$(jq -r "${jqFilter}" <<<"${probe}")"
if [[ "${jqResult}" == 'null' ]]; then
debug echo_fail "could not find lyrics for ${file}"
return 1
fi
echo "${jqResult}"
}
is_music_file() {
local file="$1"
[[ "${file}" =~ ${MUSIC_EXTENSION_REGEX} ]]
}
is_lrc_file() {
local file="$1"
[[ "${file}" == *'.lrc' ]]
}
set_lyric_file_name() { set_lyric_file_name() {
local file="$1" local file="$1"
if [[ ! "${file}" =~ ${MUSIC_EXTENSION_REGEX} ]]; then if ! is_music_file "${file}"; then
echo "${file} is not a music file" echo_fail "${file} is not a music file"
return 1 return 1
fi fi
echo "${file%.opus}.lrc" echo "${file%.opus}.lrc"
} }
is_valid_lrc() { validate_lrc() {
local file="$1" local file="$1"
# file does not exist, invalid
if [[ ! -f "${file}" ]]; then
return 1
fi
# file is not lyric file, invalid
if ! is_lrc_file "${file}"; then
return 1
fi
mapfile -t fileContents <"${file}"
local valid=1 local valid=1
while read -r line; do for line in "${fileContents[@]}"; do
[[ "${#line}" -eq 0 ]] && continue [[ "${#line}" -eq 0 ]] && continue
if [[ "${line}" != '['* ]]; then if [[ "${line}" != '['* && "${line}" != '#'* ]]; then
valid=1 valid=1
break break
fi fi
valid=0 valid=0
done <"${file}" done
if [[ ${valid} -eq 1 ]]; then
debug echo_fail "${file} is not valid lrc file"
dry rm "${file}" || return 1
fi
return ${valid} return ${valid}
} }
validate_song_lyrics() {
local songFile="$1"
local lyricsDestination="$2"
# embed or not both get validated
validate_lrc "${lyricsDestination}"
local validateDestination=$?
# only continue to validate for embed
if [[ ${EMBED} == false ]]; then
return ${validateDestination}
fi
local tmpLyricsFile="${TMPDIR}/$(bash_basename "${lyricsDestination}")"
get_file_lyrics "${songFile}" >"${tmpLyricsFile}"
validate_lrc "${tmpLyricsFile}"
local validateRet=$?
[[ ${validateRet} -eq 0 ]] && rm "${tmpLyricsFile}"
return ${validateRet}
}
add_lyrics_to_song() {
local songFile="$1"
local lyricsToAdd="$2"
local lyricsDestination="$3"
# this function removes file so be extra certain
is_music_file "${songFile}" &&
is_lrc_file "${lyricsToAdd}" &&
is_lrc_file "${lyricsDestination}" || return 1
if [[ ${EMBED} == false ]]; then
mv "${lyricsToAdd}" "${lyricsDestination}" &>/dev/null
return $?
fi
local tmpSongFile="${TMPDIR}/$(bash_basename "${songFile}")"
echo_if_fail \
ffmpeg \
-hide_banner \
-i "${songFile}" \
-metadata "LYRICS=$(<"${lyricsToAdd}")" \
-c copy \
"${tmpSongFile}"
local ffmpegRet=$?
rm "${lyricsToAdd}"
if [[ ${ffmpegRet} -eq 0 ]]; then
mv "${tmpSongFile}" "${songFile}" &>/dev/null || return 1
if [[ -f "${lyricsDestination}" ]]; then
rm "${lyricsDestination}" || return 1
fi
fi
}
+3 -3
View File
@@ -6,7 +6,7 @@
get_deezer_isrc_response() { get_deezer_isrc_response() {
local isrc="$1" local isrc="$1"
if [[ -z "${isrc}" ]]; then if [[ -z "${isrc}" ]]; then
error "missing isrc" echo_fail "missing isrc"
return 1 return 1
fi fi
@@ -20,7 +20,7 @@ get_deezer_isrc_response() {
get_deezer_url() { get_deezer_url() {
local isrc="$1" local isrc="$1"
if [[ -z "${isrc}" ]]; then if [[ -z "${isrc}" ]]; then
error "missing isrc" echo_fail "missing isrc"
return 1 return 1
fi fi
@@ -31,7 +31,7 @@ get_deezer_url() {
jqResult="$(jq -r .link <<<"${deezerResponse}")" jqResult="$(jq -r .link <<<"${deezerResponse}")"
if ! jq_result_valid "${jqResult}"; then if ! jq_result_valid "${jqResult}"; then
error "could not get link from deezer for ${isrc}" echo_fail "could not find link from deezer for ${isrc}"
return 1 return 1
fi fi
+3 -3
View File
@@ -19,11 +19,11 @@ get_lrclib_api_response() {
IFS=' ' read -r env jqFilter <<<"${data}" IFS=' ' read -r env jqFilter <<<"${data}"
jqResult="$(jq -r "${jqFilter}" <<<"${deezerResponse}")" jqResult="$(jq -r "${jqFilter}" <<<"${deezerResponse}")"
if ! jq_result_valid "${jqResult}"; then if ! jq_result_valid "${jqResult}"; then
error "could not determine lrclib ${env} for ${isrc}" echo_fail "could not determine lrclib ${env} for ${isrc}"
return 1 return 1
fi fi
declare "${env}=${jqResult}" declare "${env}=${jqResult}"
debug "${env}=${jqResult}" debug echo_debug "${env}=${jqResult}"
done done
# variables assigned above # variables assigned above
@@ -48,7 +48,7 @@ download_with_lrclib() {
lrclibResponse="$(get_lrclib_api_response "${isrc}")" lrclibResponse="$(get_lrclib_api_response "${isrc}")"
syncedLyrics="$(jq -r .syncedLyrics <<<"${lrclibResponse}")" syncedLyrics="$(jq -r .syncedLyrics <<<"${lrclibResponse}")"
if ! jq_result_valid "${syncedLyrics}"; then if ! jq_result_valid "${syncedLyrics}"; then
error "could not get lyrics for ${isrc}" debug echo_fail "could not find lyrics for ${isrc}"
return 1 return 1
fi fi
+114
View File
@@ -0,0 +1,114 @@
#!/usr/bin/env bash
FLARESOLVERR_PORT="${FLARESOLVERR_PORT:-8191}"
validate_flaresolverr() {
local checkMsg="$(curl --connect-timeout 1 -s localhost:${FLARESOLVERR_PORT} | jq -r .msg)"
if [[ "${checkMsg}" == 'FlareSolverr is ready!' ]]; then
return 0
fi
debug echo_debug 'flaresolverr not running, attempting to start with docker'
local containerName='flaresolverr'
docker inspect ${containerName} &>/dev/null && docker container rm ${containerName}
echo_if_fail docker run -d \
--name=flaresolverr \
-p ${FLARESOLVERR_PORT}:${FLARESOLVERR_PORT} \
-e LOG_LEVEL=info \
--restart unless-stopped \
ghcr.io/flaresolverr/flaresolverr:latest
local startupRet=$?
[[ ${startupRet} -ne 0 ]] && return ${startupRet}
# wait a little to make sure the container is ready
sleep 3
return ${startupRet}
}
get_lyricsify_response() {
local isrc="$1"
validate_flaresolverr || return 1
local deezerResponse=''
deezerResponse="$(get_deezer_isrc_response "${isrc}")" || return 1
# lyricsify uses artist - song name for URL
# so obtain data from deezer
local deezerMetadata=(
"track .title"
"artist .artist.name"
)
for data in "${deezerMetadata[@]}"; do
IFS=' ' read -r env jqFilter <<<"${data}"
jqResult="$(jq -r "${jqFilter}" <<<"${deezerResponse}")"
if ! jq_result_valid "${jqResult}"; then
echo_fail "could not determine lyricsify ${env} for ${isrc}"
return 1
fi
declare "${env}=${jqResult}"
debug echo_debug "${env}=${jqResult}"
done
# variables assigned above
# shellcheck disable=SC2154
artist="${artist,,}"
track="${track,,}"
artist="${artist// /-}"
track="${track// /-}"
local lyricsifyUrl="https://www.lyricsify.com/lyrics/${artist}/${track}"
debug echo_debug "using ${lyricsifyUrl}"
local jsonPayload
jsonPayload="$(
jq -n \
--arg cmd 'request.get' \
--arg url "${lyricsifyUrl}" \
--argjson timeout 60000 \
'{cmd: $cmd, url: $url, maxTimeout: $timeout}'
)" || return 1
cache_command \
curl \
-L \
-X POST \
"http://localhost:${FLARESOLVERR_PORT}/v1" \
-H 'Content-Type: application/json' \
--data-raw "${jsonPayload}"
}
process_lyricsify_response() {
local response="$1"
local responseMessage="$(jq -r '.message' <<<"${response}")"
if [[ "${responseMessage}" != 'Challenge solved!' ]]; then
debug echo_fail "could not solve challenge"
return 1
fi
local solution="$(jq -r '.solution.response' <<<"${response}")"
local variableSearch='lrcText'
while read -r line; do
if [[ "${line}" == *"var ${variableSearch} = "* ]]; then
node -e "${line} ; console.log(${variableSearch})"
return $?
fi
done <<<"${solution}"
return 1
}
DOWNLOAD_FUNCTIONS+=(download_with_lyricsify)
download_with_lyricsify() {
local isrc="$1"
local output="$2"
local lyricsifyResponse syncedLyrics
lyricsifyResponse="$(get_lyricsify_response "${isrc}")" || return 1
syncedLyrics="$(process_lyricsify_response "${lyricsifyResponse}")" || return 1
echo "${syncedLyrics}" >"${output}"
}
+16 -10
View File
@@ -10,14 +10,15 @@ OPTIONS:
-d, --dir process files in directory -d, --dir process files in directory
-r, --recurse recursively process directory -r, --recurse recursively process directory
-f, --file process one file -f, --file process one file
-n, --dry-run do not actually add synced lyrics, -e, --embed embed lyrics directly into the song file
-n, --dry-run do not add synced lyrics,
just show the files that would be processed 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 -i, --ignore ignore errors, and continue attempting to find lyrics
even in the case of errors even in the case of errors
-h, --help show this output -h, --help show this output
--debug extra information useful for debugging" --debug extra information useful for debugging
--eval output bash functions that can be directly processed
by eval for use with testing this script."
exit "${EXIT_CODE:-1}" exit "${EXIT_CODE:-1}"
} }
@@ -27,6 +28,7 @@ set_default_options() {
RECURSE=${RECURSE:-false} RECURSE=${RECURSE:-false}
DRY_RUN=${DRY_RUN:-false} DRY_RUN=${DRY_RUN:-false}
IGNORE=${IGNORE:-false} IGNORE=${IGNORE:-false}
EMBED=${EMBED:-false}
DEBUG=${DEBUG:-false} DEBUG=${DEBUG:-false}
# cache # cache
@@ -64,14 +66,10 @@ process_options() {
readonly IGNORE=true readonly IGNORE=true
shift 1 shift 1
;; ;;
--debug) -e | --embed)
readonly DEBUG=true readonly EMBED=true
shift 1 shift 1
;; ;;
-e | --eval)
output_eval_functions
exit 0
;;
-f | --file) -f | --file)
if [[ ! -f "${value}" ]]; then if [[ ! -f "${value}" ]]; then
echo "file ${value} does not exist" echo "file ${value} does not exist"
@@ -80,6 +78,14 @@ process_options() {
readonly FILE="${value}" readonly FILE="${value}"
shift 2 shift 2
;; ;;
--debug)
readonly DEBUG=true
shift 1
;;
--eval)
output_eval_functions
exit 0
;;
-h | --help) -h | --help)
EXIT_CODE=0 usage EXIT_CODE=0 usage
;; ;;
+3 -3
View File
@@ -6,7 +6,7 @@
get_spotify_url() { get_spotify_url() {
local isrc="$1" local isrc="$1"
if [[ -z "${isrc}" ]]; then if [[ -z "${isrc}" ]]; then
error "missing isrc" echo_fail "missing isrc"
return 1 return 1
fi fi
@@ -41,7 +41,7 @@ get_spotify_url() {
for tag in "${tags[@]}"; do for tag in "${tags[@]}"; do
declare -n spotifyTagValue="${tag}" declare -n spotifyTagValue="${tag}"
debug "${tag}=${spotifyTagValue}" debug echo_debug "${tag}=${spotifyTagValue}"
done done
# shellcheck disable=SC2154 # shellcheck disable=SC2154
@@ -52,7 +52,7 @@ get_spotify_url() {
done <<<"${jqOutput}" done <<<"${jqOutput}"
if [[ "${foundMatching}" == false ]]; then if [[ "${foundMatching}" == false ]]; then
error "could not find matching spotify track for ${isrc}" debug echo_fail "could not find matching spotify track for ${isrc}"
return 1 return 1
fi fi
+339 -53
View File
@@ -3,35 +3,134 @@
# "${test}" invokes test_ functions # "${test}" invokes test_ functions
# shellcheck disable=SC2329 # shellcheck disable=SC2329
set -u set -eu
readonly THIS_DIR="$(dirname "$(readlink -f "$0")")" bash_basename() {
readonly TEST_DIR="${THIS_DIR}/test-dir" local tmp
path="${1:-}"
suffix="${2:-''}"
tmp=${path%"${path##*[!/]}"}
tmp=${tmp##*/}
tmp=${tmp%"${suffix/"$tmp"/}"}
printf '%s\n' "${tmp:-/}"
}
bash_dirname() {
# Usage: dirname "path"
local tmp=${1:-.}
[[ $tmp != *[!/]* ]] && {
printf '/\n'
return
}
tmp=${tmp%%"${tmp##*[!/]}"}
[[ $tmp != */* ]] && {
printf '.\n'
return
}
tmp=${tmp%/*}
tmp=${tmp%%"${tmp##*[!/]}"}
printf '%s\n' "${tmp:-/}"
}
PROGPATH="$(readlink -f "$0")" || exit 1
PROGDIR="$(bash_dirname "${PROGPATH}")" || exit 1
PROGNAME="$(bash_basename "${PROGPATH}")" || exit 1
readonly PROGPATH PROGDIR PROGNAME || exit 1
# to normalize paths
cd "${PROGDIR}"
readonly TEST_DIR="test-dir"
# lyrics exist for this song # lyrics exist for this song
readonly GOOD_SONG="${TEST_DIR}/back burner.opus" readonly GOOD_SONG="${TEST_DIR}/the cold sun.opus"
readonly GOOD_LYRICS="${GOOD_SONG//.opus/.lrc}" readonly GOOD_LYRICS="${GOOD_SONG//.opus/.lrc}"
readonly GOOD_ISRC='TCJPF1733509'
# lyrics do not exist for this song # lyrics do not exist for this song
readonly BAD_SONG="${TEST_DIR}/subdir/avalanche.opus" readonly BAD_SONG="${TEST_DIR}/subdir/avalanche.opus"
readonly BAD_LYRICS="${BAD_SONG//.opus/.lrc}" readonly BAD_LYRICS="${BAD_SONG//.opus/.lrc}"
readonly PROG="${THIS_DIR}/add-synced-lyrics.sh" readonly BAD_ISRC='US5261822978'
# program to test
readonly PROG="add-synced-lyrics.sh"
# coverage
readonly COVERAGE_DIR="coverage"
readonly COVERAGE_FILE="${COVERAGE_DIR}/coverage"
run_test_cmd() {
local expectedRetval="${1:-}"
shift 1
local cmd=("$@")
# check for empty args for setup tests to run
[[ -z "${expectedRetval}" && -z "${cmd[*]}" ]] && return
# check a value was given
if [[ ! "${expectedRetval}" =~ [0-9] ]]; then
echo "expectedRetval not given"
exit 1
fi
local output
output="$("${cmd[@]}" 2>&1)"
local actualRetval=$?
declare -g CMD_OUTPUT="${output}"
declare -g CMD_RETURN="${actualRetval}"
if [[ ${expectedRetval} -ne ${actualRetval} ]]; then
echo "${expectedRetval} != ${actualRetval} for ${cmd[*]}"
return 1
else
return 0
fi
}
test_help_option() {
add-synced-lyrics --help
}
test_bad_input() { test_bad_input() {
# incomplete args # incomplete args
"${PROG}" && return 1 run_test_cmd \
"${PROG}" --file && return 1 1 \
"${PROG}" --dir && return 1 add-synced-lyrics
"${PROG}" --recurse && return 1 run_test_cmd \
1 \
add-synced-lyrics --file
run_test_cmd \
1 \
add-synced-lyrics --dir
run_test_cmd \
1 \
add-synced-lyrics --recurse
# incorrect mixmatch # incorrect mixmatch
"${PROG}" --file "${PROG}" --recurse && return 1 run_test_cmd \
"${PROG}" --file "${PROG}" --dir "${THIS_DIR}" && return 1 1 \
"${PROG}" --file "${PROG}" --dir "${THIS_DIR}" --recurse && return 1 add-synced-lyrics --file "${PROGPATH}" --recurse
run_test_cmd \
1 \
add-synced-lyrics --file "${PROGPATH}" --dir "${PROGDIR}"
run_test_cmd \
1 \
add-synced-lyrics --file "${PROGPATH}" --dir "${PROGDIR}" --recurse
# non-existent flag
run_test_cmd \
1 \
add-synced-lyrics --this-flag-does-not-exist
return 0 return 0
} }
test_determine_inputs() ( test_determine_inputs() (
eval "$("${PROG}" --eval)" eval "$(add-synced-lyrics --eval)"
set_default_options set_default_options
DIR="${TEST_DIR}" DIR="${TEST_DIR}"
@@ -48,12 +147,12 @@ test_determine_inputs() (
echo "comparing bash vs fd" echo "comparing bash vs fd"
diff \ diff \
<(printf '%s\n' "${bashInputs[@]}") \ <(printf '%s\n' "${bashInputs[@]}") \
<(printf '%s\n' "${fdInputs[@]}") || return 1 <(printf '%s\n' "${fdInputs[@]}")
echo "comparing fd vs find" echo "comparing fd vs find"
diff \ diff \
<(printf '%s\n' "${fdInputs[@]}") \ <(printf '%s\n' "${fdInputs[@]}") \
<(printf '%s\n' "${findInputs[@]}") || return 1 <(printf '%s\n' "${findInputs[@]}")
return 0 return 0
) )
@@ -62,48 +161,137 @@ test_determine_inputs_recurse() {
RECURSE=true test_determine_inputs RECURSE=true test_determine_inputs
} }
test_determine_inputs_bash() {
eval "$(add-synced-lyrics --eval)"
# TEST_ADD_BIN guaranteed to be single word
# shellcheck disable=SC2206
local utils=(
${utils}
${TEST_ADD_BIN:-}
)
for util in "${utils[@]}"; do
test -f "${TEST_DIR}/${util}" && rm "${TEST_DIR}/${util}"
ln -s "$(command -v "${util}")" "${TEST_DIR}/${util}"
done
PATH="${TEST_DIR}" \
add-synced-lyrics \
--dir "${TEST_DIR}" \
--dry-run "$@"
}
test_determine_inputs_bash_recurse() {
test_determine_inputs_bash --recurse
}
test_determine_inputs_find() {
TEST_ADD_BIN='find' test_determine_inputs_bash
}
test_determine_inputs_find_recurse() {
TEST_ADD_BIN='find' test_determine_inputs_bash_recurse
}
test_file_dry() { test_file_dry() {
"${PROG}" --file "${GOOD_SONG}" --dry-run setup_test_data
add-synced-lyrics --file "${GOOD_SONG}" --dry-run
} }
test_directory_dry() { test_directory_dry() {
"${PROG}" --dir "${TEST_DIR}" --dry-run setup_test_data
add-synced-lyrics --dir "${TEST_DIR}" --dry-run
} }
test_directory_recurse_dry() { test_directory_recurse_dry() {
"${PROG}" --dir "${TEST_DIR}" --recurse --dry-run setup_test_data
add-synced-lyrics --dir "${TEST_DIR}" --recurse --dry-run
} }
test_good_file() { test_good_file() {
"${PROG}" --file "${GOOD_SONG}" || return 1 setup_test_data
add-synced-lyrics --file "${GOOD_SONG}" --debug
test -f "${GOOD_LYRICS}" test -f "${GOOD_LYRICS}"
} }
test_good_file_embed() {
test_good_file
# rm "${GOOD_LYRICS}"
add-synced-lyrics --file "${GOOD_SONG}" --embed
run_test_cmd \
1 \
test -f "${GOOD_LYRICS}"
# do it again to test that embedded is picked up and valid
run_test_cmd \
0 \
add-synced-lyrics --file "${GOOD_SONG}" --embed
[[ "${CMD_OUTPUT}" == *'already has lyrics, skipping'* ]]
}
test_bad_file() { test_bad_file() {
"${PROG}" --file "${BAD_SONG}" && return 1 setup_test_data
! test -f "${BAD_LYRICS}" run_test_cmd \
1 \
add-synced-lyrics \
--file "${BAD_SONG}" \
--debug
run_test_cmd \
1 \
test -f "${BAD_LYRICS}"
} }
test_directory_recurse() { test_directory_recurse() {
setup_test_data || return 1 setup_test_data
# this test should fail since it includes the bad file # this test should fail since it includes the bad file
"${PROG}" --dir "${TEST_DIR}" --recurse && return 1 run_test_cmd \
1 \
add-synced-lyrics --dir "${TEST_DIR}" --recurse
}
test_directory_recurse_ignore() {
setup_test_data
run_test_cmd \
1 \
add-synced-lyrics --dir "${TEST_DIR}" --recurse --ignore
test -f "${GOOD_LYRICS}"
run_test_cmd \
1 \
test -f "${BAD_LYRICS}"
return 0 return 0
} }
test_directory_recurse_ignore() { test_not_music_file() {
setup_test_data || return 1 local notMusicFile="${TEST_DIR}/dummy.txt"
"${PROG}" --dir "${TEST_DIR}" --recurse --ignore && return 1 test -f "${notMusicFile}" || touch "${notMusicFile}"
test -f "${GOOD_LYRICS}" || return 1
test -f "${BAD_LYRICS}" && return 1
return 0 run_test_cmd \
1 \
add-synced-lyrics --file "${notMusicFile}"
[[ "${CMD_OUTPUT}" == *'is not a music file'* ]]
}
test_missing_utils() {
test -d "${TEST_DIR}" || setup_test_data
for bin in bash readlink; do
test -f "${TEST_DIR}/${bin}" && rm "${TEST_DIR}/${bin}"
ln -s "$(command -v ${bin})" "${TEST_DIR}/${bin}"
done
# chmod +x "${TEST_DIR}/bash"
PATH="${TEST_DIR}" run_test_cmd \
1 \
add-synced-lyrics
[[ "${CMD_OUTPUT}" == *'missing node'* ]]
} }
setup_test_data() { setup_test_data() {
test -d "${TEST_DIR}" && rm -rf "${TEST_DIR}" test -d "${TEST_DIR}" && rm -rf "${TEST_DIR}"
mkdir -p "${TEST_DIR}/subdir" || return 1 mkdir -p "${TEST_DIR}/subdir"
ffmpegCmd=( ffmpegCmd=(
ffmpeg ffmpeg
-hide_banner -hide_banner
@@ -113,50 +301,148 @@ setup_test_data() {
-t 5 -t 5
) )
"${ffmpegCmd[@]}" \ "${ffmpegCmd[@]}" \
-metadata ISRC=USTN10700139 \ -metadata ISRC=${GOOD_ISRC} \
"${GOOD_SONG}" || return 1 "${GOOD_SONG}"
"${ffmpegCmd[@]}" \ "${ffmpegCmd[@]}" \
-metadata ISRC=US5261822978 \ -metadata ISRC=${BAD_ISRC} \
"${BAD_SONG}" || return 1 "${BAD_SONG}"
} }
TESTS=( TESTS=(
setup_test_data test_help_option
test_determine_inputs test_determine_inputs
test_determine_inputs_recurse test_determine_inputs_recurse
test_determine_inputs_bash
test_determine_inputs_bash_recurse
test_determine_inputs_find
test_determine_inputs_find_recurse
test_not_music_file
test_bad_input test_bad_input
test_directory_dry test_directory_dry
test_directory_recurse_dry test_directory_recurse_dry
test_file_dry test_file_dry
test_good_file test_good_file
test_good_file_embed
test_bad_file test_bad_file
test_directory_recurse test_directory_recurse
test_directory_recurse_ignore test_directory_recurse_ignore
test_missing_utils
) )
# make sure no test is accidentally missed # make sure no test is accidentally missed
while read -r line; do while read -r line; do
IFS=' ' read -r _ _ test <<<"${line}" IFS=' ' read -r _ _ function <<<"${line}"
if [[ "${TESTS[*]}" != *"${test}"* ]]; then if [[ "${TESTS[*]}" != *"${function}"* &&
echo "missing ${test} from \$TESTS" "${function}" == 'test_'* ]]; then
echo "missing ${function} from \$TESTS"
exit 1 exit 1
fi fi
done <<<"$(declare -F)" done <<<"$(declare -F)"
for test in "${TESTS[@]}"; do # generate the coverage function for use with eval
if ! testOutput="$( # to not redefine constants
( gen_coverage_function() {
set -x # shellcheck disable=SC2016
"${test}" echo 'coverage_function () {
) 2>&1 local source line
)"; then source="${BASH_SOURCE[1]}" '"
echo source=\"\${source//'${PROGDIR}/'/}\" "'
echo "!! ${test} failed !!" line="${BASH_LINENO[0]}"
echo "${testOutput}" if [[ "${source}" == *".sh" &&
exit 1 "${source}" != *"'"${PROGNAME}"'" ]]; then
else {
echo "${test} passed" # minus one because shebang is not included
echo "${source} $((line - 1))"' ">>${COVERAGE_FILE}" '
}
fi fi
done }'
}
exit 0 setup_coverage() {
test -d "${COVERAGE_DIR}" && rm -rf "${COVERAGE_DIR}"
mkdir "${COVERAGE_DIR}"
: >"${COVERAGE_FILE}"
eval "$(gen_coverage_function)"
}
add-synced-lyrics() {
export -f coverage_function
PS4='$(coverage_function)' bash -x "${PROG}" "$@"
}
line_is_covered() {
local line="$1"
local lineNum="$2"
# empty lines are covered
[[ "${#line}" -eq 0 ]] && return 0
# commented lines are covered
read -r firstToken _ <<<"${line}"
[[ "${firstToken}" == '#'* ]] && return 0
# line is explicitly in coverage file is covered
grep -q "${coverageFile} ${lineNum}" "${COVERAGE_FILE}" && return 0
return 1
}
analyze_coverage() {
sort -u "${COVERAGE_FILE}" >"${COVERAGE_FILE}.tmp"
mv "${COVERAGE_FILE}.tmp" "${COVERAGE_FILE}"
mapfile -t coverageFiles < <(cut -d ' ' -f1 "${COVERAGE_FILE}" | sort -u)
local RED='\e[0;31m' GREEN='\e[0;32m' NC='\e[0m'
local fileDir fileLen color line linesCovered
for coverageFile in "${coverageFiles[@]}"; do
fileDir="${COVERAGE_DIR}/$(bash_dirname "${coverageFile}")"
test -d "${fileDir}" || mkdir -p "${fileDir}"
mapfile -t fileContents <"${coverageFile}"
fileLen="${#fileContents[@]}"
linesCovered=0
{
for ((line = 0; line < fileLen; line++)); do
if line_is_covered "${fileContents[${line}]}" "${line}"; then
color="${GREEN}"
linesCovered=$((linesCovered + 1))
else
color="${RED}"
fi
printf '%b%s%b\n' \
"${color}" \
"${fileContents[${line}]}" \
"${NC}"
done
} >"${COVERAGE_DIR}/${coverageFile}"
echo "${coverageFile} lines covered: $((linesCovered * 100 / fileLen))%"
done
}
if [[ $# -eq 0 ]]; then
setup_coverage
for test in "${TESTS[@]}"; do
if ! testOutput="$(
(
set -x
"${test}"
) 2>&1
)"; then
echo
echo "!! ${test} failed !!"
echo "${testOutput}"
exit 1
else
echo "${test} passed"
fi
done
echo
analyze_coverage
else
setup_coverage
"$@"
exit $?
fi