Skip to content

Instantly share code, notes, and snippets.

@jmkim
Created July 8, 2026 05:29
Show Gist options
  • Select an option

  • Save jmkim/4cd169badb74dd3ecc951ba4cbc34425 to your computer and use it in GitHub Desktop.

Select an option

Save jmkim/4cd169badb74dd3ecc951ba4cbc34425 to your computer and use it in GitHub Desktop.
Btrfs 대소문자 충돌 파일명 자동 정리 스크립트
#!/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