commit bddc943de589ad9150c0f73f43e545cc1eb30044 Author: Levon Gevorgyan Date: Fri Apr 24 16:19:01 2026 -0500 init diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..c1322dc --- /dev/null +++ b/.editorconfig @@ -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 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..116ecce --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +test-dir* \ No newline at end of file diff --git a/add-synced-lyrics.sh b/add-synced-lyrics.sh new file mode 100755 index 0000000..2ab3d1e --- /dev/null +++ b/add-synced-lyrics.sh @@ -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 diff --git a/test-add-synced-lyrics.sh b/test-add-synced-lyrics.sh new file mode 100755 index 0000000..74c3572 --- /dev/null +++ b/test-add-synced-lyrics.sh @@ -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