Created
July 8, 2026 05:29
-
-
Save jmkim/4cd169badb74dd3ecc951ba4cbc34425 to your computer and use it in GitHub Desktop.
Btrfs 대소문자 충돌 파일명 자동 정리 스크립트
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/bin/bash | |
| # | |
| # dedup-case-rename.sh | |
| # | |
| # Btrfs(대소문자 구분 파일시스템, 예: Synology DSM)에서 대소문자만 다른 | |
| # 동일 파일명들(예: img.jpg / img.JPG / IMG.jpg)에 _1, _2 ... 증분 접미사를 | |
| # 붙여 대소문자 무시 기준으로도 유일한 이름으로 만들어 주는 스크립트. | |
| # | |
| # 동작 방식: | |
| # - 디렉토리별로 파일명을 소문자화하여 그룹핑. 2개 이상이면 충돌 그룹. | |
| # - 충돌 그룹 내 정렬 순서: 소문자 우선 (img.jpg -> img.JPG -> IMG.jpg -> IMG.JPG) | |
| # - 새 이름 후보(stem_N.ext)가 기존/신규 파일과 대소문자 무시 기준으로 | |
| # 충돌하면 N을 건너뜀. (예: _3이 이미 존재하면 _4로) | |
| # - 충돌 판정은 같은 디렉토리 내에서만 수행 (다른 디렉토리는 무관) | |
| # | |
| # 사용법: | |
| # bash dedup-case-rename.sh [-n] [-r] <디렉토리> | |
| # -n, --dry-run 실제 rename 없이 결과만 출력 | |
| # -r, --recursive 하위 디렉토리까지 재귀 처리 (@eaDir, #recycle, #snapshot 제외) | |
| # | |
| # DSM 작업 스케줄러 등록 예 (사용자 정의 스크립트): | |
| # bash /volume1/scripts/dedup-case-rename.sh -r /volume1/photo >> /volume1/scripts/dedup.log 2>&1 | |
| # | |
| # 입력을 기다리는 인터랙션 없음. 종료 코드: 0=정상, 1=인자 오류, 2=rename 실패 발생 | |
| set -u | |
| export LC_ALL=C # 정렬/소문자화(ASCII)를 로케일과 무관하게 고정 | |
| usage() { | |
| echo "사용법: $(basename "$0") [-n|--dry-run] [-r|--recursive] <디렉토리>" | |
| } | |
| DRY_RUN=0 | |
| RECURSIVE=0 | |
| TARGET="" | |
| RENAMED=0 | |
| ERRORS=0 | |
| while (($#)); do | |
| case "$1" in | |
| -n|--dry-run) DRY_RUN=1 ;; | |
| -r|--recursive) RECURSIVE=1 ;; | |
| -h|--help) usage; exit 0 ;; | |
| --) shift | |
| if (($# == 1)) && [[ -z "$TARGET" ]]; then | |
| TARGET="$1" | |
| else | |
| usage >&2; exit 1 | |
| fi | |
| break ;; | |
| -*) echo "알 수 없는 옵션: $1" >&2; usage >&2; exit 1 ;; | |
| *) if [[ -z "$TARGET" ]]; then | |
| TARGET="$1" | |
| else | |
| echo "디렉토리 인자는 하나만 지정할 수 있습니다." >&2 | |
| usage >&2; exit 1 | |
| fi ;; | |
| esac | |
| shift | |
| done | |
| if [[ -z "$TARGET" ]]; then | |
| usage >&2 | |
| exit 1 | |
| fi | |
| if [[ ! -d "$TARGET" ]]; then | |
| echo "디렉토리가 아니거나 존재하지 않습니다: $TARGET" >&2 | |
| exit 1 | |
| fi | |
| process_dir() { | |
| local dir="$1" | |
| local -a files=() | |
| local f name lc | |
| # 디렉토리 최상위의 일반 파일만 수집 (심볼릭 링크 제외) | |
| shopt -s nullglob dotglob | |
| for f in "$dir"/*; do | |
| [[ -f "$f" && ! -L "$f" ]] && files+=("${f##*/}") | |
| done | |
| shopt -u nullglob dotglob | |
| ((${#files[@]} < 2)) && return 0 | |
| # taken: 이 디렉토리에서 사용 중인 소문자화 이름 집합 (충돌 판정용) | |
| # group: 소문자화 이름 -> '/'로 연결한 원본 이름 목록 ('/'는 파일명에 못 들어감) | |
| local -A taken=() | |
| local -A group=() | |
| local -A count=() | |
| for name in "${files[@]}"; do | |
| lc="${name,,}" | |
| taken["$lc"]=1 | |
| group["$lc"]="${group["$lc"]:-}/$name" | |
| count["$lc"]=$(( ${count["$lc"]:-0} + 1 )) | |
| done | |
| for lc in "${!count[@]}"; do | |
| (( count["$lc"] < 2 )) && continue | |
| local -a members=() | |
| IFS='/' read -r -a members <<< "${group[$lc]#/}" | |
| # 바이트 내림차순 삽입 정렬 = 소문자가 앞으로 (img.jpg, img.JPG, IMG.jpg, IMG.JPG) | |
| local i j tmp | |
| for ((i = 1; i < ${#members[@]}; i++)); do | |
| tmp="${members[i]}" | |
| j=$((i - 1)) | |
| while ((j >= 0)) && [[ "${members[j]}" < "$tmp" ]]; do | |
| members[j + 1]="${members[j]}" | |
| j=$((j - 1)) | |
| done | |
| members[j + 1]="$tmp" | |
| done | |
| local n=1 stem ext candidate lc_cand | |
| for name in "${members[@]}"; do | |
| if [[ "$name" == ?*.* ]]; then | |
| stem="${name%.*}" | |
| ext=".${name##*.}" | |
| else | |
| stem="$name" | |
| ext="" | |
| fi | |
| # 대소문자 무시 기준으로 비어 있는 첫 번째 N 찾기 | |
| while :; do | |
| candidate="${stem}_${n}${ext}" | |
| lc_cand="${candidate,,}" | |
| if [[ -z "${taken[$lc_cand]:-}" && ! -e "$dir/$candidate" ]]; then | |
| break | |
| fi | |
| n=$((n + 1)) | |
| done | |
| taken["$lc_cand"]=1 | |
| n=$((n + 1)) | |
| if ((DRY_RUN)); then | |
| echo "[DRY-RUN] $dir/$name -> $dir/$candidate" | |
| RENAMED=$((RENAMED + 1)) | |
| elif mv -n -- "$dir/$name" "$dir/$candidate"; then | |
| echo "renamed: $dir/$name -> $dir/$candidate" | |
| RENAMED=$((RENAMED + 1)) | |
| else | |
| echo "오류: rename 실패: $dir/$name" >&2 | |
| ERRORS=$((ERRORS + 1)) | |
| fi | |
| done | |
| done | |
| return 0 | |
| } | |
| if ((RECURSIVE)); then | |
| # Synology 메타데이터/휴지통/스냅샷 디렉토리는 건너뜀 | |
| while IFS= read -r -d '' d; do | |
| process_dir "$d" | |
| done < <(find "$TARGET" \( -name '@eaDir' -o -name '#recycle' -o -name '#snapshot' \) -prune -o -type d -print0) | |
| else | |
| process_dir "$TARGET" | |
| fi | |
| if ((DRY_RUN)); then | |
| echo "완료(dry-run): ${RENAMED}개 파일이 rename 대상입니다." | |
| else | |
| echo "완료: ${RENAMED}개 파일 rename, ${ERRORS}개 오류." | |
| fi | |
| ((ERRORS)) && exit 2 | |
| exit 0 |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment