mirror of
https://github.com/levogevo/add-synced-lyrics.git
synced 2026-07-21 21:45:21 +00:00
init
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
# EditorConfig is awesome: https://EditorConfig.org
|
||||
|
||||
# top-most EditorConfig file
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = false
|
||||
insert_final_newline = false
|
||||
@@ -0,0 +1 @@
|
||||
test-dir*
|
||||
Executable
+376
@@ -0,0 +1,376 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -u
|
||||
|
||||
have_cmd() {
|
||||
for cmd in "$@"; do
|
||||
command -v "${cmd}" &>/dev/null || return 1
|
||||
done
|
||||
}
|
||||
|
||||
# shellcheck disable=SC2329
|
||||
void() {
|
||||
echo "$@" &>/dev/null
|
||||
}
|
||||
|
||||
# shellcheck disable=SC2329
|
||||
dry() {
|
||||
local args=("$@")
|
||||
local numArgs="${#args[@]}"
|
||||
test "${numArgs}" -eq 0 && return 0
|
||||
echo "'${args[0]}'"
|
||||
local earlyStop=$((numArgs - 1))
|
||||
for ((i = 1; i < earlyStop; i++)); do
|
||||
echo " '${args[${i}]}' \\"
|
||||
done
|
||||
echo " '${args[${i}]}'"
|
||||
}
|
||||
|
||||
check_required_utils() {
|
||||
local utils=(
|
||||
ffmpeg
|
||||
jq
|
||||
syncedlyrics
|
||||
librelyrics
|
||||
spotify # uv tool install --python 3.8 spotify-cli
|
||||
)
|
||||
local missing=false
|
||||
for util in "${utils[@]}"; do
|
||||
if ! have_cmd "${util}"; then
|
||||
echo "missing ${util}!"
|
||||
missing=true
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ ${missing} == true ]]; then
|
||||
return 1
|
||||
else
|
||||
return 0
|
||||
fi
|
||||
}
|
||||
|
||||
# shellcheck disable=SC2329
|
||||
get_spotify_url() {
|
||||
local search="$1"
|
||||
local response artistAlbumTitle url
|
||||
response="$(
|
||||
spotify \
|
||||
search \
|
||||
--track "${search}" \
|
||||
--limit 1 \
|
||||
--raw
|
||||
)"
|
||||
|
||||
artistAlbumTitle="$(
|
||||
jq -r '.items[0] | "\(.artists[0].name) - \(.album.name) - \(.name)"' \
|
||||
<<<"${response}"
|
||||
)"
|
||||
|
||||
if [[ "${artistAlbumTitle}" != "${search}" ]]; then
|
||||
echo "${search} could not be found"
|
||||
return 1
|
||||
fi
|
||||
|
||||
url="$(
|
||||
jq -r '.items[0] | "\(.external_urls.spotify)"' <<<"${response}"
|
||||
)"
|
||||
|
||||
if [[ -z "${url}" ]]; then
|
||||
echo "could not determine url for ${search}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "${url}"
|
||||
}
|
||||
|
||||
output_eval_functions() {
|
||||
while read -r line; do
|
||||
IFS=' ' read -r _ _ function <<<"${line}"
|
||||
[[ "${function}" == '_determine_inputs_'* ||
|
||||
"${function}" == set_default_options ]] || continue
|
||||
${line}
|
||||
done <<<"$(declare -F)"
|
||||
}
|
||||
|
||||
get_artist_album_title() {
|
||||
local file="$1"
|
||||
local jqFilter='.streams[] | select(.codec_type == "audio") | .tags | "\(.ARTIST) - \(.ALBUM) - \(.TITLE)"'
|
||||
ffprobe \
|
||||
-v error \
|
||||
-show_entries stream \
|
||||
-of json \
|
||||
"${file}" | jq \
|
||||
-r "${jqFilter}"
|
||||
}
|
||||
|
||||
set_lyric_file_name() {
|
||||
local file="$1"
|
||||
if [[ ! "${file}" =~ ${MUSIC_EXTENSION_REGEX} ]]; then
|
||||
echo "${file} is not a music file"
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "${file%.opus}.lrc"
|
||||
}
|
||||
|
||||
bash_basename() {
|
||||
local tmp
|
||||
path="$1"
|
||||
suffix="${2:-''}"
|
||||
|
||||
tmp=${path%"${path##*[!/]}"}
|
||||
tmp=${tmp##*/}
|
||||
tmp=${tmp%"${suffix/"$tmp"/}"}
|
||||
|
||||
printf '%s\n' "${tmp:-/}"
|
||||
}
|
||||
|
||||
readonly PROGNAME="$(bash_basename "$0")"
|
||||
|
||||
usage() {
|
||||
echo "${PROGNAME} [options]
|
||||
|
||||
OPTIONS:
|
||||
-d, --dir process files in directory
|
||||
-r, --recurse recursively process directory
|
||||
-f, --file process one file
|
||||
-n, --dry-run do not actually add synced lyrics,
|
||||
just show the files that would be processed
|
||||
-e, --eval output bash functions that can be directly processed
|
||||
by eval for use with testing this script.
|
||||
-i, --ignore ignore errors, and continue attempting to find lyrics
|
||||
even in the case of errors
|
||||
-h, --help show this output"
|
||||
exit ${EXIT_CODE:-1}
|
||||
}
|
||||
|
||||
set_default_options() {
|
||||
DIR=${DIR:-}
|
||||
FILE=${FILE:-}
|
||||
RECURSE=${RECURSE:-false}
|
||||
DRY_RUN=${DRY_RUN:-false}
|
||||
IGNORE=${IGNORE:-false}
|
||||
readonly MUSIC_EXTENSION_REGEX='.*\.(opus)$'
|
||||
}
|
||||
|
||||
process_options() {
|
||||
while [[ $# -gt 0 ]]; do
|
||||
option="$1"
|
||||
value="${2:-}"
|
||||
case "${option}" in
|
||||
-d | --dir)
|
||||
if [[ ! -d "${value}" ]]; then
|
||||
echo "directory ${value} does not exist"
|
||||
usage
|
||||
fi
|
||||
readonly DIR="${value}"
|
||||
shift 2
|
||||
;;
|
||||
-r | --recurse)
|
||||
readonly RECURSE=true
|
||||
shift 1
|
||||
;;
|
||||
-n | --dry-run)
|
||||
readonly DRY_RUN=true
|
||||
shift 1
|
||||
;;
|
||||
-i | --ignore)
|
||||
readonly IGNORE=true
|
||||
shift 1
|
||||
;;
|
||||
-e | --eval)
|
||||
output_eval_functions
|
||||
exit 0
|
||||
;;
|
||||
-f | --file)
|
||||
if [[ ! -f "${value}" ]]; then
|
||||
echo "file ${value} does not exist"
|
||||
usage
|
||||
fi
|
||||
readonly FILE="${value}"
|
||||
shift 2
|
||||
;;
|
||||
-h | --help)
|
||||
EXIT_CODE=0 usage
|
||||
;;
|
||||
*)
|
||||
echo "bad option"
|
||||
usage
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ "${DIR:-}${FILE:-}" == '' ]]; then
|
||||
echo "either --dir or --file must be used"
|
||||
usage
|
||||
fi
|
||||
|
||||
if [[ -n "${DIR}" && -n "${FILE}" ]]; then
|
||||
echo "cannot process both directory and file"
|
||||
usage
|
||||
fi
|
||||
|
||||
if [[ "${RECURSE}" == true && -n "${FILE}" ]]; then
|
||||
echo "--recurse can only be used with --dir"
|
||||
usage
|
||||
fi
|
||||
}
|
||||
|
||||
_determine_inputs_fd() {
|
||||
local fdCmd=(
|
||||
fd
|
||||
--type f
|
||||
"${MUSIC_EXTENSION_REGEX}"
|
||||
"${DIR}"
|
||||
)
|
||||
|
||||
if [[ "${RECURSE}" == false ]]; then
|
||||
fdCmd+=(--max-depth 1)
|
||||
fi
|
||||
|
||||
mapfile -t INPUTS < <("${fdCmd[@]}" | sort)
|
||||
}
|
||||
|
||||
_determine_inputs_find() {
|
||||
local findCmd=(
|
||||
find
|
||||
"${DIR}"
|
||||
)
|
||||
|
||||
if [[ "${RECURSE}" == false ]]; then
|
||||
findCmd+=(-maxdepth 1)
|
||||
fi
|
||||
|
||||
findCmd+=(
|
||||
-regextype posix-extended
|
||||
-regex "${MUSIC_EXTENSION_REGEX}"
|
||||
-type f
|
||||
)
|
||||
|
||||
mapfile -t INPUTS < <("${findCmd[@]}" | sort)
|
||||
}
|
||||
|
||||
_determine_inputs_bash() {
|
||||
local unsortedInputs=()
|
||||
if [[ "${RECURSE}" == true ]]; then
|
||||
local preGlobstar="$(shopt -p globstar)"
|
||||
shopt -s globstar
|
||||
for f in "${DIR}"/**/*; do
|
||||
unsortedInputs+=("${f}")
|
||||
done
|
||||
${preGlobstar}
|
||||
else
|
||||
for f in "${DIR}"/**; do
|
||||
unsortedInputs+=("${f}")
|
||||
done
|
||||
fi
|
||||
|
||||
local filteredInputs=()
|
||||
for input in "${unsortedInputs[@]}"; do
|
||||
[[ "${input}" =~ ${MUSIC_EXTENSION_REGEX} ]] || continue
|
||||
filteredInputs+=("${input}")
|
||||
done
|
||||
|
||||
mapfile -t INPUTS < <(printf '%s\n' "${filteredInputs[@]}" | sort)
|
||||
}
|
||||
|
||||
determine_inputs() {
|
||||
INPUTS=()
|
||||
if [[ -n "${FILE}" ]]; then
|
||||
INPUTS=("${FILE}")
|
||||
elif have_cmd fd; then
|
||||
_determine_inputs_fd || return 1
|
||||
elif have_cmd find; then
|
||||
_determine_inputs_find || return 1
|
||||
else
|
||||
_determine_inputs_bash || return 1
|
||||
fi
|
||||
|
||||
if [[ "${#INPUTS[@]}" -eq 0 ]]; then
|
||||
echo "no inputs found, something is wrong"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
readonly INPUTS
|
||||
}
|
||||
|
||||
create_tmp_dir() {
|
||||
TMPDIR="$(mktemp -d)" || return 1
|
||||
readonly TMPDIR
|
||||
trap 'rm -rf ${TMPDIR}' EXIT
|
||||
}
|
||||
|
||||
process_inputs() {
|
||||
local dry='' void=''
|
||||
if [[ "${DRY_RUN}" == true ]]; then
|
||||
dry=dry
|
||||
void=void
|
||||
fi
|
||||
|
||||
local search='' output='' spotifyUrl='' ret=''
|
||||
for input in "${INPUTS[@]}"; do
|
||||
if [[ ${IGNORE} == false && ${ret} -ne 0 ]]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ "${input,,}" == *'instrumental'* ]]; then
|
||||
echo "${input} is instrumental, skipping"
|
||||
continue
|
||||
fi
|
||||
|
||||
output="$(set_lyric_file_name "${input}")"
|
||||
if [[ ! -f "${output}" ]]; then
|
||||
echo "processing ${input}"
|
||||
search="$(get_artist_album_title "${input}")"
|
||||
|
||||
# try with syncedlyrics first
|
||||
${dry} syncedlyrics \
|
||||
--synced-only \
|
||||
--output "${output}" \
|
||||
"${search}"
|
||||
|
||||
if ! ${void} test -f "${output}"; then
|
||||
for f in "${TMPDIR}"/*; do
|
||||
if [[ -f "${f}" ]]; then
|
||||
echo "tempdir ${TMPDIR} is not empty!"
|
||||
echo "aborting operation"
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
|
||||
# try with librelyrics-spotify next
|
||||
if ! spotifyUrl="$(${dry} get_spotify_url "${search}")"; then
|
||||
ret=1
|
||||
continue
|
||||
fi
|
||||
|
||||
if ! ${dry} librelyrics \
|
||||
--directory "${TMPDIR}" \
|
||||
"${spotifyUrl}"; then
|
||||
ret=1
|
||||
continue
|
||||
fi
|
||||
|
||||
if ! ${dry} mv "${TMPDIR}"/*.lrc "${output}"; then
|
||||
ret=1
|
||||
continue
|
||||
fi
|
||||
fi
|
||||
|
||||
if ! ${void} test -f "${output}"; then
|
||||
echo "failed to get lyrics for ${input}"
|
||||
ret=1
|
||||
continue
|
||||
fi
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
check_required_utils || exit 1
|
||||
set_default_options || exit 1
|
||||
process_options "$@" || exit 1
|
||||
determine_inputs || exit 1
|
||||
create_tmp_dir || exit 1
|
||||
process_inputs || exit 1
|
||||
|
||||
exit 0
|
||||
Executable
+146
@@ -0,0 +1,146 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# "${test}" invokes test_ functions
|
||||
# shellcheck disable=SC2329
|
||||
|
||||
set -u
|
||||
|
||||
readonly THIS_DIR="$(dirname "$(readlink -f "$0")")"
|
||||
readonly TEST_DIR="${THIS_DIR}/test-dir"
|
||||
readonly TEST_FILE_1="${TEST_DIR}/03 - Back Burner.opus"
|
||||
readonly TEST_FILE_2="${TEST_DIR}/subdir/05 - Composure.opus"
|
||||
readonly PROG="${THIS_DIR}/add-synced-lyrics.sh"
|
||||
|
||||
test_bad_input() {
|
||||
# incomplete args
|
||||
"${PROG}" && return 1
|
||||
"${PROG}" --file && return 1
|
||||
"${PROG}" --dir && return 1
|
||||
"${PROG}" --recurse && return 1
|
||||
|
||||
# incorrect mixmatch
|
||||
"${PROG}" --file "${PROG}" --recurse && return 1
|
||||
"${PROG}" --file "${PROG}" --dir "${THIS_DIR}" && return 1
|
||||
"${PROG}" --file "${PROG}" --dir "${THIS_DIR}" --recurse && return 1
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
test_determine_inputs() (
|
||||
eval "$("${PROG}" --eval)"
|
||||
set_default_options
|
||||
|
||||
DIR="${TEST_DIR}"
|
||||
|
||||
_determine_inputs_bash
|
||||
bashInputs=("${INPUTS[@]}")
|
||||
|
||||
_determine_inputs_fd
|
||||
fdInputs=("${INPUTS[@]}")
|
||||
|
||||
_determine_inputs_find
|
||||
findInputs=("${INPUTS[@]}")
|
||||
|
||||
echo "comparing bash vs fd"
|
||||
diff \
|
||||
<(printf '%s\n' "${bashInputs[@]}") \
|
||||
<(printf '%s\n' "${fdInputs[@]}") || return 1
|
||||
|
||||
echo "comparing fd vs find"
|
||||
diff \
|
||||
<(printf '%s\n' "${fdInputs[@]}") \
|
||||
<(printf '%s\n' "${findInputs[@]}") || return 1
|
||||
|
||||
return 0
|
||||
)
|
||||
|
||||
test_determine_inputs_recurse() {
|
||||
RECURSE=true test_determine_inputs
|
||||
}
|
||||
|
||||
test_file_dry() {
|
||||
"${PROG}" --file "${TEST_FILE_1}" --dry-run || return 1
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
test_directory_dry() {
|
||||
"${PROG}" --dir "${TEST_DIR}" --dry-run || return 1
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
test_directory_recurse_dry() {
|
||||
"${PROG}" --dir "${TEST_DIR}" --recurse --dry-run || return 1
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
test_file() {
|
||||
"${PROG}" --file "${TEST_FILE_1}" || return 1
|
||||
test -f "${TEST_FILE_1//.opus/.lrc}"
|
||||
}
|
||||
|
||||
test_directory_recurse() {
|
||||
"${PROG}" --dir "${TEST_DIR}" --recurse || return 1
|
||||
test -f "${TEST_FILE_2//.opus/.lrc}"
|
||||
}
|
||||
|
||||
setup_test_data() {
|
||||
test -d "${TEST_DIR}" && rm -rf "${TEST_DIR}"
|
||||
mkdir -p "${TEST_DIR}/subdir" || return 1
|
||||
ffmpegCmd=(
|
||||
ffmpeg
|
||||
-hide_banner
|
||||
-f lavfi
|
||||
-i anullsrc=channel_layout=stereo:sample_rate=48000
|
||||
-c:a libopus
|
||||
-t 5
|
||||
-metadata ALBUM="Messengers"
|
||||
-metadata ARTIST="August Burns Red"
|
||||
)
|
||||
"${ffmpegCmd[@]}" \
|
||||
-metadata TITLE="Back Burner" \
|
||||
"${TEST_FILE_1}" || return 1
|
||||
"${ffmpegCmd[@]}" \
|
||||
-metadata TITLE="Composure" \
|
||||
"${TEST_FILE_2}" || return 1
|
||||
}
|
||||
|
||||
TESTS=(
|
||||
setup_test_data
|
||||
test_determine_inputs
|
||||
test_determine_inputs_recurse
|
||||
test_bad_input
|
||||
test_directory_dry
|
||||
test_directory_recurse_dry
|
||||
test_file_dry
|
||||
test_file
|
||||
test_directory_recurse
|
||||
)
|
||||
|
||||
# make sure no test is accidentally missed
|
||||
while read -r line; do
|
||||
IFS=' ' read -r _ _ test <<<"${line}"
|
||||
if [[ "${TESTS[*]}" != *"${test}"* ]]; then
|
||||
echo "missing ${test} from \$TESTS"
|
||||
exit 1
|
||||
fi
|
||||
done <<<"$(declare -F)"
|
||||
|
||||
for test in "${TESTS[@]}"; do
|
||||
if ! testOutput="$(
|
||||
(
|
||||
set -x
|
||||
"${test}"
|
||||
) 2>&1
|
||||
)"; then
|
||||
echo "!! ${test} failed !!"
|
||||
echo "${testOutput}"
|
||||
exit 1
|
||||
else
|
||||
echo "${test} passed"
|
||||
fi
|
||||
done
|
||||
|
||||
exit 0
|
||||
Reference in New Issue
Block a user