#!/usr/bin/env bash # ============================================================================= # organize_photos.sh # ============================================================================= # Organizes files in the ownCloud Instant Upload folders into a consistent # date-based folder structure: SUBDIR/YYYY/MM/filename # # The ownCloud Android app pre-sorts videos into YYYY/MM/ subfolders # automatically, but uses YYYY/YYYY-MM/ for images. This script: # 1. Moves any new files dropped in the SUBDIR root into YYYY/MM/ # 2. Migrates old YYYY/YYYY-MM/ folders → YYYY/MM/ (removes redundant year) # # Processed subdirectories (under BASE/): # Camera – phone photos and videos (instant upload) # BlueIris – BlueIris NVR clip exports # Facebook – Facebook photo downloads # scans – scanned documents # EufyVideoDir – Eufy security camera recordings # # Files with no recognisable date go to BASE/_unsorted/ unchanged. # # Date extraction tries these patterns in order (first match wins): # 1. YYYYMMDD embedded anywhere in the filename (e.g. PXL_20260802_...) # 2. YYYY-MM-DD or YYYY_MM_DD separator format (e.g. 2026-08-02) # 3. 13-digit Unix millisecond timestamp (e.g. 1722614400000) # 4. MM_DD_YYYY_ prefix (e.g. 08_02_2026_clip) # # Scheduling: run via cron or systemd timer. Example cron (every 15 min): # */15 * * * * /path/to/organize_photos.sh # # Log file: /var/log/organize_photos.log # ============================================================================= BASE="/mnt/INTEL-SSD/ownCloud - Joe Gitta@cloud.jgitta.com/Personal/InstantUpload" UNSORTED="$BASE/_unsorted" LOG="/var/log/organize_photos.log" moved=0; skipped=0; unsorted=0; errors=0 echo "=== $(date +%Y-%m-%d\ %H:%M:%S) Starting ===" >> "$LOG" # ----------------------------------------------------------------------------- # ts_to_ym TIMESTAMP_MS # Converts a 13-digit Unix millisecond timestamp to "YYYY MM". # ----------------------------------------------------------------------------- ts_to_ym() { local ts_ms="$1" local ts_s=$(( ts_ms / 1000 )) date -d "@$ts_s" "+%Y %m" 2>/dev/null } # ----------------------------------------------------------------------------- # get_ym FILEPATH # Extracts "YYYY MM" from a filename using the patterns described in the header. # Prints "YYYY MM" on success, empty string if no date found. # ----------------------------------------------------------------------------- get_ym() { local f="$1" local b b=$(basename "$f") # Pattern 1: YYYYMMDD anywhere in filename (e.g. PXL_20260802_171610514.mp4) if [[ "$b" =~ (^|[^0-9])([0-9]{4})(0[1-9]|1[0-2])(0[1-9]|[12][0-9]|3[01]) ]]; then echo "${BASH_REMATCH[2]} ${BASH_REMATCH[3]}"; return fi # Pattern 2: YYYY-MM-DD or YYYY_MM_DD (e.g. 2026-08-02_clip.mp4) if [[ "$b" =~ ([0-9]{4})[-_](0[1-9]|1[0-2])[-_](0[1-9]|[12][0-9]|3[01]) ]]; then echo "${BASH_REMATCH[1]} ${BASH_REMATCH[2]}"; return fi # Pattern 3: 13-digit Unix ms timestamp (e.g. 1722614400000_eufy.mp4) if [[ "$b" =~ (^|[^0-9])([0-9]{13})([^0-9]|$) ]]; then ts_to_ym "${BASH_REMATCH[2]}"; return fi # Pattern 4: MM_DD_YYYY_ prefix (e.g. 08_02_2026_doorbell.mp4) if [[ "$b" =~ ^([0-9]{2})_([0-9]{2})_([0-9]{4})_ ]]; then echo "${BASH_REMATCH[3]} ${BASH_REMATCH[1]}"; return fi echo "" } # ----------------------------------------------------------------------------- # move_file SRC ROOT # Moves SRC into ROOT/YYYY/MM/filename using the date extracted from the # filename. Sends undatable files to UNSORTED. Skips if destination exists. # ----------------------------------------------------------------------------- move_file() { local src="$1" local root="$2" local fname fname=$(basename "$src") local ym ym=$(get_ym "$src") if [[ -z "$ym" ]]; then # No date found — move to _unsorted for manual review mkdir -p "$UNSORTED" if [[ ! -e "$UNSORTED/$fname" ]]; then mv "$src" "$UNSORTED/$fname" && ((unsorted++)) || ((errors++)) else ((skipped++)) fi return fi local year month dest_dir dest year=$(echo "$ym" | cut -d' ' -f1) month=$(echo "$ym" | cut -d' ' -f2) dest_dir="$root/$year/$month" dest="$dest_dir/$fname" mkdir -p "$dest_dir" if [[ -e "$dest" ]]; then ((skipped++)); return fi mv "$src" "$dest" && ((moved++)) || { echo " ERROR: $fname" >> "$LOG"; ((errors++)); } } # ============================================================================= # Main loop — process each instant-upload subdirectory # ============================================================================= for SUBDIR in Camera BlueIris Facebook scans EufyVideoDir; do SUBPATH="$BASE/$SUBDIR" [[ -d "$SUBPATH" ]] || continue # --- Pass 1: files dropped directly into the subdir root ---------------- # These are new uploads not yet organised by the Android app. while IFS= read -r -d '' f; do move_file "$f" "$SUBPATH" done < <(find "$SUBPATH" -maxdepth 1 -type f -print0) # --- Pass 2: migrate old YYYY/YYYY-MM/ folders → YYYY/MM/ --------------- # Older versions of this script (and some ownCloud client versions) created # YYYY/YYYY-MM/ subfolders, which redundantly repeat the year. This pass # flattens them to YYYY/MM/ and removes the now-empty YYYY-MM directory. for year_dir in "$SUBPATH"/[0-9][0-9][0-9][0-9]; do [[ -d "$year_dir" ]] || continue year=$(basename "$year_dir") for old_dir in "$year_dir"/"$year"-[0-9][0-9]; do [[ -d "$old_dir" ]] || continue month="${old_dir##*-}" # extract MM from YYYY-MM folder name dest_dir="$SUBPATH/$year/$month" mkdir -p "$dest_dir" while IFS= read -r -d '' f; do fname=$(basename "$f") dest="$dest_dir/$fname" if [[ ! -e "$dest" ]]; then mv "$f" "$dest" && ((moved++)) || { echo " ERROR: $fname" >> "$LOG"; ((errors++)); } else ((skipped++)) fi done < <(find "$old_dir" -maxdepth 1 -type f -print0) rmdir "$old_dir" 2>/dev/null # Remove if now empty done done done echo " Moved: $moved Unsorted: $unsorted Skipped: $skipped Errors: $errors" >> "$LOG" echo "=== Done ===" >> "$LOG"