From ffb5cafaf32d2af04e3f3020d67f404e0a797aee Mon Sep 17 00:00:00 2001 From: Levon Gevorgyan Date: Wed, 29 Apr 2026 17:42:01 -0500 Subject: [PATCH] add --embed --- add-synced-lyrics.sh | 17 +++-- lib/0-utils.sh | 131 +++++++++++++++++++++++++++++++++++--- lib/options.sh | 24 ++++--- test-add-synced-lyrics.sh | 34 +++++++++- 4 files changed, 182 insertions(+), 24 deletions(-) diff --git a/add-synced-lyrics.sh b/add-synced-lyrics.sh index 9251b22..8de0591 100755 --- a/add-synced-lyrics.sh +++ b/add-synced-lyrics.sh @@ -74,9 +74,13 @@ process_inputs() { fi 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 validate_lrc "${output}"; then + if validate_song_lyrics "${input}" "${output}"; then echo_pass "${input} already has lyrics, skipping" continue fi @@ -87,7 +91,7 @@ process_inputs() { continue fi - local tmpOutput="${TMPDIR}/tmp.lrc" + local tmpOutput="${TMPDIR}/$(bash_basename "${output}")" for func in "${downloadFuncs[@]}"; do # try to download if ! "${func}" "${isrc}" "${tmpOutput}"; then @@ -105,8 +109,11 @@ process_inputs() { [[ ${DRY_RUN} == true ]] && continue if [[ -f "${tmpOutput}" ]]; then - mv "${tmpOutput}" "${output}" &>/dev/null || return 1 - echo_pass "added lyrics for ${input}" + add_lyrics_to_song \ + "${input}" \ + "${tmpOutput}" \ + "${output}" && + echo_pass "added lyrics for ${input}" else echo_fail "could not find lyrics for ${input}" ret=1 diff --git a/lib/0-utils.sh b/lib/0-utils.sh index ccf4ea8..ca3e881 100644 --- a/lib/0-utils.sh +++ b/lib/0-utils.sh @@ -32,7 +32,7 @@ stderr() { local word="${WORD:-}" local color="${COLOR:-}" local function="${FUNCNAME[2]}" - if [[ "${function}" =~ void ]]; then + if [[ "${function}" =~ debug|void ]]; then function="${FUNCNAME[3]}" fi echo \ @@ -44,7 +44,7 @@ stderr() { echo_info() { WORD=INFO COLOR="${CYAN}" stderr "$@"; } echo_fail() { WORD=FAIL COLOR="${RED}" stderr "$@"; } echo_pass() { WORD=PASS COLOR="${GREEN}" stderr "$@"; } -echo_dbug() { WORD=DBUG COLOR="${PURPLE}" stderr "$@"; } +echo_debug() { WORD=DBUG COLOR="${PURPLE}" stderr "$@"; } # only perform the command given if ${DEBUG} is enabled debug() { @@ -111,6 +111,20 @@ check_required_utils() { 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() { local cmd=("$@") local hash cacheFile cmdOut contents ret @@ -149,8 +163,10 @@ check_tmp_dir_empty() { for f in "${TMPDIR}"/*; do if [[ -f "${f}" ]]; then echo_fail "tempdir ${TMPDIR} is not empty!" + echo_fail "contains ${f}" echo_fail "aborting operation" - return 1 + # execution becomes undefined if TMPDIR is not empty + exit 1 fi done @@ -255,10 +271,43 @@ get_file_isrc() { 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() { local file="$1" - if [[ ! "${file}" =~ ${MUSIC_EXTENSION_REGEX} ]]; then - echo "${file} is not a music file" + if ! is_music_file "${file}"; then + echo_fail "${file} is not a music file" return 1 fi @@ -268,19 +317,27 @@ set_lyric_file_name() { validate_lrc() { local file="$1" - # file does not exist, is invalid + # file does not exist, invalid if [[ ! -f "${file}" ]]; then return 1 fi - local valid=0 - while read -r line; do + # file is not lyric file, invalid + if ! is_lrc_file "${file}"; then + return 1 + fi + + mapfile -t fileContents <"${file}" + + local valid=1 + for line in "${fileContents[@]}"; do [[ "${#line}" -eq 0 ]] && continue if [[ "${line}" != '['* && "${line}" != '#'* ]]; then valid=1 break fi - done <"${file}" + valid=0 + done if [[ ${valid} -eq 1 ]]; then debug echo_fail "${file} is not valid lrc file" @@ -289,3 +346,59 @@ validate_lrc() { 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 +} diff --git a/lib/options.sh b/lib/options.sh index 6da63d9..a8054ab 100644 --- a/lib/options.sh +++ b/lib/options.sh @@ -10,14 +10,15 @@ 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 - -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 - --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}" } @@ -27,6 +28,7 @@ set_default_options() { RECURSE=${RECURSE:-false} DRY_RUN=${DRY_RUN:-false} IGNORE=${IGNORE:-false} + EMBED=${EMBED:-false} DEBUG=${DEBUG:-false} # cache @@ -64,14 +66,10 @@ process_options() { readonly IGNORE=true shift 1 ;; - --debug) - readonly DEBUG=true + -e | --embed) + readonly EMBED=true shift 1 ;; - -e | --eval) - output_eval_functions - exit 0 - ;; -f | --file) if [[ ! -f "${value}" ]]; then echo "file ${value} does not exist" @@ -80,6 +78,14 @@ process_options() { readonly FILE="${value}" shift 2 ;; + --debug) + readonly DEBUG=true + shift 1 + ;; + --eval) + output_eval_functions + exit 0 + ;; -h | --help) EXIT_CODE=0 usage ;; diff --git a/test-add-synced-lyrics.sh b/test-add-synced-lyrics.sh index 57cd4cf..7a10efb 100755 --- a/test-add-synced-lyrics.sh +++ b/test-add-synced-lyrics.sh @@ -74,7 +74,7 @@ run_test_cmd() { fi local output - output="$("${cmd[@]}")" + output="$("${cmd[@]}" 2>&1)" local actualRetval=$? declare -g CMD_OUTPUT="${output}" @@ -88,6 +88,10 @@ run_test_cmd() { } +test_help_option() { + add-synced-lyrics --help +} + test_bad_input() { # incomplete args run_test_cmd \ @@ -114,6 +118,11 @@ test_bad_input() { 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 } @@ -174,23 +183,43 @@ test_determine_inputs_find_recurse() { } test_file_dry() { + setup_test_data add-synced-lyrics --file "${GOOD_SONG}" --dry-run } test_directory_dry() { + setup_test_data add-synced-lyrics --dir "${TEST_DIR}" --dry-run } test_directory_recurse_dry() { + setup_test_data add-synced-lyrics --dir "${TEST_DIR}" --recurse --dry-run } test_good_file() { + setup_test_data add-synced-lyrics --file "${GOOD_SONG}" --debug 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() { + setup_test_data run_test_cmd \ 1 \ add-synced-lyrics \ @@ -248,6 +277,7 @@ setup_test_data() { } TESTS=( + test_help_option test_determine_inputs test_determine_inputs_recurse test_determine_inputs_find @@ -257,6 +287,7 @@ TESTS=( test_directory_recurse_dry test_file_dry test_good_file + test_good_file_embed test_bad_file test_directory_recurse test_directory_recurse_ignore @@ -376,5 +407,6 @@ if [[ $# -eq 0 ]]; then echo analyze_coverage else + setup_coverage "$@" fi