mirror of
https://github.com/levogevo/add-synced-lyrics.git
synced 2026-07-21 21:45:21 +00:00
Compare commits
3
Commits
ffb5cafaf3
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc27d4493c | ||
|
|
3427522cb6 | ||
|
|
c0c7364e4a |
@@ -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.
|
||||||
@@ -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,6 +56,7 @@ process_inputs() {
|
|||||||
download_with_lrclib
|
download_with_lrclib
|
||||||
download_with_spotify
|
download_with_spotify
|
||||||
download_with_deezer
|
download_with_deezer
|
||||||
|
download_with_lyricsify
|
||||||
)
|
)
|
||||||
|
|
||||||
local numInputs="${#INPUTS[@]}"
|
local numInputs="${#INPUTS[@]}"
|
||||||
|
|||||||
+25
-6
@@ -89,13 +89,29 @@ 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
|
||||||
@@ -131,11 +147,13 @@ cache_command() {
|
|||||||
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=$?
|
||||||
@@ -246,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() {
|
||||||
|
|||||||
@@ -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
@@ -39,20 +39,24 @@ bash_dirname() {
|
|||||||
printf '%s\n' "${tmp:-/}"
|
printf '%s\n' "${tmp:-/}"
|
||||||
}
|
}
|
||||||
|
|
||||||
readonly PROGPATH="$(readlink -f "$0")"
|
PROGPATH="$(readlink -f "$0")" || exit 1
|
||||||
readonly PROGDIR="$(bash_dirname "${PROGPATH}")"
|
PROGDIR="$(bash_dirname "${PROGPATH}")" || exit 1
|
||||||
readonly PROGNAME="$(bash_basename "${PROGPATH}")"
|
PROGNAME="$(bash_basename "${PROGPATH}")" || exit 1
|
||||||
|
readonly PROGPATH PROGDIR PROGNAME || exit 1
|
||||||
|
|
||||||
# to normalize paths
|
# to normalize paths
|
||||||
cd "${PROGDIR}"
|
cd "${PROGDIR}"
|
||||||
|
|
||||||
readonly TEST_DIR="test-dir"
|
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 BAD_ISRC='US5261822978'
|
||||||
|
# program to test
|
||||||
readonly PROG="add-synced-lyrics.sh"
|
readonly PROG="add-synced-lyrics.sh"
|
||||||
# coverage
|
# coverage
|
||||||
readonly COVERAGE_DIR="coverage"
|
readonly COVERAGE_DIR="coverage"
|
||||||
@@ -85,7 +89,6 @@ run_test_cmd() {
|
|||||||
else
|
else
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
test_help_option() {
|
test_help_option() {
|
||||||
@@ -158,28 +161,36 @@ test_determine_inputs_recurse() {
|
|||||||
RECURSE=true test_determine_inputs
|
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=(
|
local utils=(
|
||||||
base64
|
${utils}
|
||||||
cat
|
${TEST_ADD_BIN:-}
|
||||||
ffprobe
|
|
||||||
jq
|
|
||||||
librelyrics
|
|
||||||
spotify
|
|
||||||
)
|
)
|
||||||
local path=()
|
|
||||||
for util in "${utils[@]}"; do
|
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
|
done
|
||||||
IFS=':' moddedPath="${path[*]}"
|
|
||||||
PATH="${moddedPath}" \
|
PATH="${TEST_DIR}" \
|
||||||
add-synced-lyrics \
|
add-synced-lyrics \
|
||||||
--dir "${TEST_DIR}" \
|
--dir "${TEST_DIR}" \
|
||||||
--dry-run "$@"
|
--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_determine_inputs_find --recurse
|
TEST_ADD_BIN='find' test_determine_inputs_bash_recurse
|
||||||
}
|
}
|
||||||
|
|
||||||
test_file_dry() {
|
test_file_dry() {
|
||||||
@@ -251,10 +262,31 @@ test_directory_recurse_ignore() {
|
|||||||
return 0
|
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() {
|
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 \
|
1 \
|
||||||
add-synced-lyrics
|
add-synced-lyrics
|
||||||
|
|
||||||
|
[[ "${CMD_OUTPUT}" == *'missing node'* ]]
|
||||||
}
|
}
|
||||||
|
|
||||||
setup_test_data() {
|
setup_test_data() {
|
||||||
@@ -269,10 +301,10 @@ setup_test_data() {
|
|||||||
-t 5
|
-t 5
|
||||||
)
|
)
|
||||||
"${ffmpegCmd[@]}" \
|
"${ffmpegCmd[@]}" \
|
||||||
-metadata ISRC=USTN10700139 \
|
-metadata ISRC=${GOOD_ISRC} \
|
||||||
"${GOOD_SONG}"
|
"${GOOD_SONG}"
|
||||||
"${ffmpegCmd[@]}" \
|
"${ffmpegCmd[@]}" \
|
||||||
-metadata ISRC=US5261822978 \
|
-metadata ISRC=${BAD_ISRC} \
|
||||||
"${BAD_SONG}"
|
"${BAD_SONG}"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,8 +312,11 @@ TESTS=(
|
|||||||
test_help_option
|
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
|
||||||
test_determine_inputs_find_recurse
|
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
|
||||||
@@ -409,4 +444,5 @@ if [[ $# -eq 0 ]]; then
|
|||||||
else
|
else
|
||||||
setup_coverage
|
setup_coverage
|
||||||
"$@"
|
"$@"
|
||||||
|
exit $?
|
||||||
fi
|
fi
|
||||||
|
|||||||
Reference in New Issue
Block a user