commit 084d62225cd39ab4733216d6c201c4aeec2e77a7 Author: Tobias Langner Date: Tue Apr 2 16:09:34 2024 +0200 Initial commit of bash script. diff --git a/photos_bestof.sh b/photos_bestof.sh new file mode 100755 index 0000000..49192e6 --- /dev/null +++ b/photos_bestof.sh @@ -0,0 +1,201 @@ +#!/bin/bash + +source "$( dirname $( readlink -f "$0" ) )/shflags.sh" + +# Filter options +DEFINE_integer min_rating 3 "The min rating for files to be processed (0-5)." +DEFINE_integer max_age_days -1 "The maximum age (since the creation date) of an image to be processed." +DEFINE_string exiftool_filter "" "Only process images that satisfy the condition, which must be a string that can be evaluated by exiftool's -if statement." + +# Path options +DEFINE_string photos_dir "" "The directory that contains the photos to be processed." +DEFINE_string bestof_dir "" "The directory where the photos above --min_rating should be copied to. Path will be created if it does not exist." + +# Process options +DEFINE_boolean resize false "Whether to resize the photos before copying to --resolution." +DEFINE_string resize_to 1920 "The resolution to which to resize the photos if --resize is set. Can be anything that \\\`convert -resize\\\` accepts." +DEFINE_boolean flatten false "Whether to flatten the directory structure to have all image files in 'best_of' directory." +DEFINE_boolean force "false" "Whether to force reprocessing of all files instead of just new files." +DEFINE_boolean prune "false" "Whether to remove files already existing in the bestof folder that do not meet the filter criteria." +DEFINE_boolean use_hardlinks "false" "Whether to use hardlinks instead of copying. Cannot be combined with --resize." + +# Debug options +DEFINE_boolean verbose false "Whether to print all actions performed." +DEFINE_boolean dry_run false "Only print what would be done but do not do anything." +DEFINE_boolean debug false "Whether to print bash debug output." + +set -euo pipefail + +EXCLUDE_MARKER=".bestof-exclude" +PROCESSED_FILES_STORE=".bestof-processed" + +function error() { + echo "$@" >&2 +} + +function verbose() { + if [[ ${FLAGS_verbose} -eq ${FLAGS_TRUE} ]]; then + error "$@" + fi +} + +function paths_in_same_filesystem() { + fs_id1=$( stat -c "%d" "$1" ) + fs_id2=$( stat -c "%d" "$2" ) + [[ "$fs_id1" = "$fs_id2" ]] +} + +function validate_flags() { + [[ ! -d "${FLAGS_photos_dir}" ]] && error "--photos_dir must be set and point to a valid directory." && return 1 + [[ -z "${FLAGS_bestof_dir}" ]] && error "--bestof_dir must be set." && return 1 + if [[ ${FLAGS_resize} -eq ${FLAGS_TRUE} ]]; then + [[ -z "${FLAGS_resize_to}" ]] && error "--resize_to cannot be empty when --resize is given." && return 1 + fi + + if [[ ${FLAGS_use_hardlinks} -eq ${FLAGS_TRUE} ]]; then + if [[ ${FLAGS_resize} -eq ${FLAGS_TRUE} ]]; then + error "--use_hardlinks and --resize are incompatible." + return 1 + fi + + if ! paths_in_same_filesystem "${FLAGS_photos_dir}" "${FLAGS_bestof_dir}"; then + error "--use_hardlinks can only be used when --photos_dir and --bestof_dir are in the same filesystem." + return 1 + fi + fi + return 0 +} + +function process_flags() { + if [[ ${FLAGS_dry_run} -eq ${FLAGS_TRUE} ]]; then + echo "Dry-run: Not doing anything..." + fi + + if [[ ${FLAGS_debug} -eq ${FLAGS_TRUE} ]]; then + set -x + fi + mkdir -p "${FLAGS_bestof_dir}" + + return 0 +} + +function is_already_processed() { + if [[ ${FLAGS_force} -eq ${FLAGS_FALSE} ]] && \ + [[ -f "$PROCESSED_FILES_STORE_PATH" ]] && \ + look -b "$photo_path" "$PROCESSED_FILES_STORE_PATH" &> /dev/null; then + # Return that file does not need to be processed again. + return 0 + fi + + # Mark file as processed for next time. + echo "$photo_path" >> "$PROCESSED_FILES_STORE_PATH" + + # Return that file should be processed. + return 1 +} + +function maybe_run() { + if [[ ${FLAGS_dry_run} -eq ${FLAGS_FALSE} ]]; then + # Run the command + "$@" + fi +} + +function file_passes_filters() { + local file_path + file_path="$1" + + condition='$Rating >= '${FLAGS_min_rating} + if [[ ${FLAGS_max_age_days} -gt -1 ]]; then + condition="$condition and ( not ShiftTime(\$now,\"-${FLAGS_max_age_days} 0\") and \$CreateDate gt \$now )" + fi + if [[ -n "${FLAGS_exiftool_filter}" ]]; then + condition="$condition and ${FLAGS_exiftool_filter}" + fi + # Use -TagDoesNotExist to suppress any output by exiftool as we only care about the return value. + verbose "exiftool -if \"$condition\" -TagDoesNotExist -q -q -m \"$photo_path\"" + exiftool -if "$condition" -TagDoesNotExist -q -q -m "$photo_path" +} + +function main() { + validate_flags + process_flags + + PROCESSED_FILES_STORE_PATH="${FLAGS_bestof_dir}/$PROCESSED_FILES_STORE" + [[ -f "$PROCESSED_FILES_STORE_PATH" ]] && LC_COLLATE=C sort -u -d -o "$PROCESSED_FILES_STORE_PATH" "$PROCESSED_FILES_STORE_PATH" + + # Exclude directories containing the .bestof-exclude marker. See https://unix.stackexchange.com/questions/468734/find-exclude-path-containing-specific-files for details. + # Also exclude the bestof dir if it is part of the photos_dir + total=$( find "${FLAGS_photos_dir}" \( -exec [ -f {}/.bestof-exclude ] \; -prune \) \ + -o -path "${FLAGS_bestof_dir}" -prune \ + -o -iname "*.jpg" -print | wc -l ) + + count=0 + processed_cnt=0 + while read photo_path; do + count=$(( $count + 1 )); + if [[ $(( $count % 10 )) -eq 0 ]] || [[ $count -eq $total ]]; then + echo "$( date +%T ) Visited $count/$total image files. Images copied to best-of folder: $processed_cnt..." + fi + + verbose "Processing file $photo_path." + + # Only record processed state when not doing a dry run. + if [[ ${FLAGS_dry_run} -eq ${FLAGS_FALSE} ]] && is_already_processed; then + verbose " Skipping file as it was already processed." + continue + fi + + rel_path="${photo_path#${FLAGS_photos_dir}}" + if [[ ${FLAGS_flatten} -eq ${FLAGS_TRUE} ]]; then + # Drop the complete path if we are flattening. + rel_path=$( basename "$rel_path" ) + fi + bestof_path="${FLAGS_bestof_dir}/${rel_path}" + + # Delete file with the same name if it already exists. + [[ -f "${bestof_path}" ]] && rm "${bestof_path}" + + if ! file_passes_filters "$photo_path"; then + verbose " File does not pass filters." + continue + fi + + # Create all parent directories. + mkdir -p "${bestof_path%/*}" + if [[ ${FLAGS_resize} -eq ${FLAGS_TRUE} ]]; then + verbose " Resizing file into target directory." + maybe_run convert "${photo_path}" -resize "${FLAGS_resize_to}" "${bestof_path}" + else + if [[ ${FLAGS_use_hardlinks} -eq ${FLAGS_TRUE} ]]; then + verbose " Hardlinking file into target directory." + maybe_run ln "${photo_path}" "${bestof_path}" + else + verbose " Copying file into target directory." + maybe_run cp "${photo_path}" "${bestof_path}" + fi + fi + processed_cnt=$(( $processed_cnt + 1 )) + done < <( find "${FLAGS_photos_dir}" \( -exec [ -f "{}/$EXCLUDE_MARKER" ] \; -prune \) \ + -o -path "${FLAGS_bestof_dir}" -prune \ + -o -iname "*.jpg" -print ) + + pruned_cnt=0 + if [[ ${FLAGS_prune} -eq ${FLAGS_TRUE} ]]; then + echo "Pruning filtered files in ${FLAGS_bestof_dir}..." + while read photo_path; do + echo "Processing file $photo_path." + if ! file_passes_filters "$photo_path"; then + verbose " Pruning file as it does not pass the filters." + maybe_run rm "$photo_path" + pruned_cnt=$(( $pruned_cnt + 1 )) + fi + done < <( find "${FLAGS_bestof_dir}" -iname "*.jpg" ) + [[ $pruned_cnt -gt 0 ]] && echo "Pruned $pruned_cnt files from best-of directory." + fi +} + +# Parse the command-line +FLAGS "$@" || exit $? +eval set -- "${FLAGS_ARGV}" +main "$@"