Compare commits

...
3 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
5 changed files with 234 additions and 29 deletions
+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.
+4 -3
View File
@@ -39,9 +39,9 @@ bash_dirname() {
printf '%s\n' "${tmp:-/}"
}
PROGPATH="$(readlink -f "$0")"
PROGDIR="$(bash_dirname "${PROGPATH}")"
PROGNAME="$(bash_basename "${PROGPATH}")"
PROGPATH="$(readlink -f "$0")" || exit 1
PROGDIR="$(bash_dirname "${PROGPATH}")" || exit 1
PROGNAME="$(bash_basename "${PROGPATH}")" || exit 1
# shellcheck disable=SC2034
readonly PROGPATH PROGDIR PROGNAME || exit 1
@@ -56,6 +56,7 @@ process_inputs() {
download_with_lrclib
download_with_spotify
download_with_deezer
download_with_lyricsify
)
local numInputs="${#INPUTS[@]}"
+25 -6
View File
@@ -89,13 +89,29 @@ dry() {
# check required utilities for this project
check_required_utils() {
local utils=(
base64
# coreutils
bash
cat
ffprobe
sort
base64
mktemp
readlink
jq
node
docker
ffmpeg
ffprobe
librelyrics
spotify # uv tool install --python 3.8 spotify-cli
spotify
)
if [[ "${1:-}" == '--print-required' ]]; then
echo "utils='${utils[*]}'"
return
fi
local missing=false
for util in "${utils[@]}"; do
if ! have_cmd "${util}"; then
@@ -131,11 +147,13 @@ cache_command() {
hash="$(sha256sum <<<"${cmd[*]}")" || return 1
read -r cacheFile _ <<<"${hash}"
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}"
ret=$?
# skip first line since it is the command
printf '%s\n' "${contents[@]:1}"
# skip N lines respective to the command length
printf '%s\n' "${contents[@]:${skipLines}}"
else
cmdOut="$(dry "${cmd[@]}")"
ret=$?
@@ -246,6 +264,7 @@ output_eval_functions() {
"${function}" == set_default_options ]] || continue
${line}
done <<<"$(declare -F)"
check_required_utils --print-required
}
get_file_isrc() {
+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}"
}
+56 -20
View File
@@ -39,20 +39,24 @@ bash_dirname() {
printf '%s\n' "${tmp:-/}"
}
readonly PROGPATH="$(readlink -f "$0")"
readonly PROGDIR="$(bash_dirname "${PROGPATH}")"
readonly PROGNAME="$(bash_basename "${PROGPATH}")"
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
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_ISRC='TCJPF1733509'
# lyrics do not exist for this song
readonly BAD_SONG="${TEST_DIR}/subdir/avalanche.opus"
readonly BAD_LYRICS="${BAD_SONG//.opus/.lrc}"
readonly BAD_ISRC='US5261822978'
# program to test
readonly PROG="add-synced-lyrics.sh"
# coverage
readonly COVERAGE_DIR="coverage"
@@ -85,7 +89,6 @@ run_test_cmd() {
else
return 0
fi
}
test_help_option() {
@@ -158,28 +161,36 @@ test_determine_inputs_recurse() {
RECURSE=true test_determine_inputs
}
test_determine_inputs_find() {
test_determine_inputs_bash() {
eval "$(add-synced-lyrics --eval)"
# TEST_ADD_BIN guaranteed to be single word
# shellcheck disable=SC2206
local utils=(
base64
cat
ffprobe
jq
librelyrics
spotify
${utils}
${TEST_ADD_BIN:-}
)
local path=()
for util in "${utils[@]}"; do
path+=("$(bash_dirname "$(command -v "${util}")")")
test -f "${TEST_DIR}/${util}" && rm "${TEST_DIR}/${util}"
ln -s "$(command -v "${util}")" "${TEST_DIR}/${util}"
done
IFS=':' moddedPath="${path[*]}"
PATH="${moddedPath}" \
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_determine_inputs_find --recurse
TEST_ADD_BIN='find' test_determine_inputs_bash_recurse
}
test_file_dry() {
@@ -251,10 +262,31 @@ test_directory_recurse_ignore() {
return 0
}
test_not_music_file() {
local notMusicFile="${TEST_DIR}/dummy.txt"
test -f "${notMusicFile}" || touch "${notMusicFile}"
run_test_cmd \
1 \
add-synced-lyrics --file "${notMusicFile}"
[[ "${CMD_OUTPUT}" == *'is not a music file'* ]]
}
test_missing_utils() {
PATH="$(bash_dirname "$(command -v bash)")" run_test_cmd \
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() {
@@ -269,10 +301,10 @@ setup_test_data() {
-t 5
)
"${ffmpegCmd[@]}" \
-metadata ISRC=USTN10700139 \
-metadata ISRC=${GOOD_ISRC} \
"${GOOD_SONG}"
"${ffmpegCmd[@]}" \
-metadata ISRC=US5261822978 \
-metadata ISRC=${BAD_ISRC} \
"${BAD_SONG}"
}
@@ -280,8 +312,11 @@ TESTS=(
test_help_option
test_determine_inputs
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_directory_dry
test_directory_recurse_dry
@@ -409,4 +444,5 @@ if [[ $# -eq 0 ]]; then
else
setup_coverage
"$@"
exit $?
fi