mirror of
https://github.com/levogevo/add-synced-lyrics.git
synced 2026-07-21 21:45:21 +00:00
Compare commits
7
Commits
1ea04833aa
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc27d4493c | ||
|
|
3427522cb6 | ||
|
|
c0c7364e4a | ||
|
|
ffb5cafaf3 | ||
|
|
5ecfd07ad2 | ||
|
|
3fec74f772 | ||
|
|
5b25505990 |
@@ -1,2 +1,3 @@
|
|||||||
test-dir*
|
test-dir*
|
||||||
*.json
|
*.json
|
||||||
|
coverage*
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user