#!/bin/bash -u
#
# Edit the tags of Ogg Vorbis and FLAC files in an editor.
#
# Usage: vorbiscommentedit [-m] [file [file [file...] ] ]
#
# By default, only the tags that have the same value in all files are
# shown in the editor. Adding new tags adds them to all files and
# overrides any tags of the same name that might already have been
# present.
#
# With -m, all tags of all files (and the file names, too) are edited.
#
# The file names (only with option -m) and tags from the files that
# are given on the command line are collected in a temporary file. An
# editor ($VISUAL, $EDITOR, or, failing those, some well-known editor)
# is started and when it returns, the tags are written back to the
# files and the files are renamed (if the name was edited).
#
# Without option -m, only the tags that have the same value in all
# files are shown in the editor, each tag only once.
#
# With option -m, each file is represented in the temporary file as a
# set of lines:
#
#   : path/to/current/file/name
#   + path/to/new/file/name
#   tag1=value1
#   tag2=value2
#   tag3=value3
#   ...
#
# Tags are currently only read from Ogg Vorbis and FLAC files. Other
# files are represented only by their name.
#
# Multi-line values are shown on one line with \n and \r replacing LF
# and CR. Backslashes are doubled (\\). A zero character may be
# inserted with \0. (These escapes only work for Ogg files currently;
# metaflac doesn't recognize them yet.)
#
# TODO: metaflac doesn't handle escapes (\n, \r, \\, \0)...
#
# TODO: Handle tags in MP3 files. And maybe other files, too.
#
# TODO: Allow tags before the first file to set common tags for all files?
#
# TODO: Do something sensible if files are readable but not writable.
#
# This program was inspired by vorbistagedit (by martin f. krafft
# <madduck@madduck.net>). Compared to vorbistagedit, this program also
# handles FLAC files and allows to rename (but not change tags in)
# arbitrary other files. (Handy if you have, e.g., files music.ogg and
# music.info and want to rename both.) Unlike vorbistagedit, it
# doesn't allow to set common tags for all files in the preamble. It
# also doesn't rely on file extensions but uses the "file" command to
# get the file's type (which makes it slower, though). The -e option
# of vorbiscomment allows editing multi-line tag
# values. (Unfortunately, metaflac doesn't have that option yet.)
#
# Author: Bert Bos <bert@w3.org>
# Created: 17 Nov 2007
# Modified: 8 December 2018

VERSION=1.4

set -o pipefail			# A pipeline fails if any component fails


# get-tags -- extract tags from Ogg or FLAC
function get-tags
{
  # Using "file" is safer than the file extension, but much slower...
  case $(file "$1") in
    *": Ogg "*) vorbiscomment -e -l "$1";;
    *": FLAC "*) metaflac --export-tags-to=- "$1";;
    *) return 1;;
  esac
  # case "$1" in
  #   *.ogg|*.ogx|*.ogv|*.oga|*.spx) vorbiscomment -e -l "$1";;
  #   *.flac) metaflac --export-tags-to=- "$1";;
  # esac
}


# set-tags -- replace tags in Ogg or FLAC with those from stdin
function set-tags
{
  # vorbiscomment and metaflac will replace files even if they are not
  # writable. But we don't want such files to change.
  if [[ ! -w "$1" ]]; then
    echo "${0##*/}: File is not writable: $1" >&2
    return
  fi

  # Using "file" is safer than the file extension, but much slower...
  case $(file "$1") in
    *": Ogg "*) vorbiscomment -e -w -c - "$1";;
    *": FLAC "*) metaflac --remove-all-tags --import-tags-from=- "$1";;
  esac
  # case "$1" in
  #   *.ogg|*.ogx|*.ogv|*.oga|*.spx) vorbiscomment -e -w -c - "$1";;
  #   *.flac) metaflac --remove-all-tags --import-tags-from=- "$1";;
  # esac
}


# one-line-per-key -- put multivalued fields on one line, separated by tab
function one-line-per-key
{
  awk -F = '
    { key = toupper($1)
      if (!curline) {curkey = key; curline = $0}
      else if (key == curkey) {curline = curline "	" $0}
      else {print curline; curkey = key; curline = $0} }
    END {if (curline) print curline}'
}


# process -- rename $1 to $2 and write tags from file $3 to it
function process
{
  echo -e ".\c" >&3
  if [[ "$2" != "$1" ]]; then
    mkdir -p $(dirname "$2") && mv "$1" "$2"
  fi
  if [[ -f "$3" ]]; then
    set-tags "$2" <$3
    unlink $3
  fi
}


# do-multiple -- edit all tags and names of all files
function do-multiple
{
  local f TMP TMP1 file="" new line s

  trap 'rm -f $TMP $TMP1' RETURN
  TMP=$(mktemp /tmp/tags-XXXXXX) || return 1
  TMP1=$(mktemp /tmp/tags-XXXXXX) || return 1

  # Print preamble 
  #
  cat >$TMP <<-EOF
	# Edit, remove or add TAG= lines.
	# Edit the "+" line to rename the file.
	# Empty lines and lines starting with "#" are ignored.
	# Don't change the :" lines!
	#
	# For Ogg files:
	#   - Backslashes must be doubled (\\\\).
	#   - Use \n, \r and \0 to for LF, CR, and the null character, resp.
	# (Multi-line tags are not supported for FLAC files yet.)

	
	EOF

  # Collect tags
  #
  echo -e "  Reading \c" >&3
  for f; do
    echo -e ".\c" >&3
    echo ": $f"
    echo "+ $f"
    get-tags "$f"
    echo
  done >>$TMP
  echo >&3

  # Keep the checksum. Let the user edit.
  #
  s=$(cksum <$TMP)
  $EDIT $TMP

  # After it returns, check if something actually changed. If so,
  # collect the new tags for each file in TMP1 and update each file
  #
  if [[ "$s" = $(cksum <$TMP) ]]; then echo "  No change" >&3; return; fi

  echo -e "  Writing \c" >&3
  while read -r line; do
    case "$line" in
      ": "*) [[ -n "$file" ]] && process "$file" "$new" $TMP1; file=${line#: };;
      "+ "*) [[ -n "$file" ]] && new=${line#+ };;
      "#"*|"") ;;
      *=*) [[ -n "$file" ]] && echo -E "$line" >>$TMP1;;
      *) echo "${0##*/}: Warning: Illegal line: \"$line\"" >&2;;
    esac
  done <$TMP
  [[ -n "$file" ]] && process "$file" "$new" $TMP1
  echo >&3
}


# do-common -- write the common tags to the file arguments
function do-common
{
  local f seenfirst= COMMON OTHER TMP1 TMP2 TMP3 tag

  trap 'rm -f $COMMON $OTHERTAGS $TMP1 $TMP2 $TMP3' RETURN
  COMMON=`mktemp /tmp/vceXXXXXXXXXX` || return 1
  OTHERTAGS=`mktemp /tmp/vceXXXXXXXXXX` || return 1
  TMP1=`mktemp /tmp/vceXXXXXXXXXX` || return 1
  TMP2=`mktemp /tmp/vceXXXXXXXXXX` || return 1
  TMP3=`mktemp /tmp/vceXXXXXXXXXX` || return 1

  # Get all common fields of the given files into $COMMON
  # Get the tags of all non-common fields into $OTHERTAGS
  #
  echo -e "  Reading \c" >&3
  for f; do
    echo -e ".\c" >&3
    if get-tags "$f" | sort -fu | one-line-per-key >$TMP1; then
      if [ "$seenfirst" ]; then
	comm -12 $COMMON $TMP1 >$TMP2
	cut -d= -f1 <(comm -23 $COMMON $TMP1) <(comm -13 $COMMON $TMP1) \
	  | sort -fu - $OTHERTAGS >$TMP3
	mv $TMP2 $COMMON
	mv $TMP3 $OTHERTAGS
      else
	mv $TMP1 $COMMON
	seenfirst=true
      fi
    fi
  done
  echo >&3

  # Add the fields that weren't common, prefixed with a "~", so the
  # user can see what other fields exist.
  #
  cat <(tr '\t' '\n' <$COMMON) <(sed -e 's/^/~ /' $OTHERTAGS) >$TMP1 \
    && mv $TMP1 $COMMON

  # Keep the checksum. Let the user edit.
  #
  s=$(cksum <$COMMON)
  $EDIT $COMMON

  # After it returns, check if something actually changed. If so,
  # collect the new tags for each file in TMP1 and update each file
  #
  if [[ "$s" = $(cksum <$COMMON) ]]; then echo "  No change" >&3; return; fi

  # Remove everything but the common tags.
  #
  egrep -v '^[ 	]*$|^~|^#' $COMMON >$TMP1
  mv $TMP1 $COMMON

  # Check if the result is reasonably well-formed
  #
  LC_ALL=C egrep -q -v '^[ -}]+=' $COMMON \
    && echo "${0##*/}: Malformed lines" >&2 && return 1

  # If the user edited fields that existed, but were previously not
  # common to the ogg files, remove their tags from OTHERTAGS (the list of
  # tags that were not common), because the user wants them to become
  # common.
  #
  for tag in $(cut -d= -f1 $COMMON | sort -fu); do
    egrep -v -i "^$tag\$" $OTHERTAGS >$TMP1
    mv $TMP1 $OTHERTAGS
  done

  # Put modified fields back. Copy all fields that were not in the set
  # that was edited (and should therefore not be touched), concatenate
  # the result with the edited set, and write the result into the Ogg
  # file.
  #
  echo -e "  Writing \c" >&3
  for f; do
    echo -e ".\c" >&3
    get-tags "$f" >$TMP1
    for tag in $(< $OTHERTAGS); do egrep -i "^$tag=" $TMP1; done >$TMP2
    cat $COMMON $TMP2 | set-tags "$f"
  done
  echo >&3
}


# usage -- print usage message and exit
function usage
{
  echo "Usage: ${0##*/} [-m] file [file...]"
  exit
}


# Main body
#
action=common
while getopts "mhV" opt; do
  case $opt in
    m) action=multiple;;
    h) usage;;
    V) echo "${0##*/} $VERSION"; exit;;
    *) echo "${0##*/}: Illegal option -$opt. Try -h" >&2; exit 1;;
  esac
done
shift $((OPTIND - 1))
if [[ "${#@}" -lt 1 ]]; then
  echo "${0##*/}: Missing file argument(s). Try -h" >&2
  exit 1
fi

# Find which editor to use, VISUAL, EDITOR or some well-known editor
#
for f in ${VISUAL:-} ${EDITOR:-} sensible-editor vim emacs editor nano vi; do
  command -v "$f" >/dev/null && EDIT=$f && break
done
if [[ -z "${EDIT:-}" ]]; then
  echo  "${0##*/}: No editor found. Try setting VISUAL or EDITOR." >&2; exit 1
fi

# Make file descriptor &3 a duplicate of &1. We will use it for some
# progress information.
#
exec 3>&1

# Call the appropriate routine to process the files.
#
if [[ $action == multiple ]]; then
  do-multiple "$@"
else
  do-common "$@"
fi
exit $?
