补充说明
该脚本用于将源根目录下「每个一级子文件夹」中的文件,整体迁移到目标根目录下「同名子文件夹」中,保持原有目录结构不变。遍历源目录的子文件夹时自动在目标目录 mkdir -p 建目录,默认以 预览(preview)模式只列出待迁移文件,确认后再以 run 模式真实执行 mv,避免误操作。
支持按文件后缀排除(逗号分隔,如 mp4,mkv)、按文件大小排除(大于 N 十进制 MB 不移动),并支持命令行传参与交互式输入两种方式。子目录(非文件)始终被完整迁移,仅对文件应用后缀/大小过滤。
原理等价于在源目录每个子文件夹上执行:
find 源/子文件夹 -maxdepth 1 -type f | xargs mv -t 目标/子文件夹
但脚本额外做了三层增强:结构对应建目录、后缀/大小双过滤、安全预览。
一键脚本
bash <(curl -sL gitee.com/meimolihan/cmdbox/raw/master/sh/batch_mv_subdir.sh)
bash <(curl -sL ../sh/batch_mv_subdir.sh)
命令行模式示例(脚本名以实际部署路径为准,以下为本地脚本名):
# 只传源和目标,后面三个参数全部省略,默认:不排除后缀、不限制大小、预览
./batch_mv_subdir.sh /vol/src /vol/dst
# 预览,排除mp4,大于10MB十进制不移动
./batch_mv_subdir.sh /vol/src /vol/dst mp4 10 preview
# 真实执行
./batch_mv_subdir.sh /vol/src /vol/dst mp4 10 run
运行环境
- 系统:Linux(未使用 GNU 以外的特殊工具)
- 依赖:
find/mv/mkdir/basename/awk/sleep(均属于 coreutils / findutils 标准组件,通常已预装) - 建议终端:支持 ANSI 颜色与
\r刷新(动画提示用)
无需安装 ffmpeg,不涉及任何多媒体编解码。
功能特点
- 按一级子文件夹批量迁移,目标目录自动创建同名结构
- 双运行模式:
preview预览 /run执行移动,默认 preview - 后缀排除:
! -name "*.$ext",支持逗号分隔多个后缀(如mp4,mkv,avi) - 大小排除:大于指定「十进制 MB」的文件不移动,支持小数(如
0.5) - 子目录整体迁移:目录不再进行后缀/大小过滤,避免误排目录
- 命令行传参与交互式输入双通道,交互模式带完整配置汇总与二次确认
- run 模式执行
find -exec mv {} 目标/ \;,逐文件安全迁移 - 彩色日志分级:信息 / 成功 / 警告 / 错误 一目了然
- 内置退出动画、空输入返回、无效输入重试等交互友好提示
参数说明
| 参数 | 含义 | 默认值 | 示例 |
|---|---|---|---|
$1 |
源根目录 | 必填 | /vol/src |
$2 |
目标根目录 | 必填 | /vol/dst |
$3 |
排除文件后缀(逗号分隔) | 无 | mp4,mkv |
$4 |
排除大于 N MB 的文件(十进制) | 无 | 10 或 0.5 |
$5 |
运行模式 | preview |
preview / run |
只传 2 个参数时即「不排除后缀、不限制大小、仅预览」;传
run前请务必先用preview确认列表。
不传任何参数时进入交互模式,依次询问源目录、目标目录、排除后缀、排除大小、运行模式,最后展示「配置汇总」并要求 y/N 确认。
运行模式说明
preview(默认):遍历源目录每个子文件夹,打印【待处理列表】后直接进入下一个目录,不产生任何移动。
run:先打印【待处理列表】,随后在列表基础上追加 -exec mv {} 目标目录/ \; 真实执行移动;全部处理完成后输出「🎉 全部任务结束」。
无论哪种模式,目标子目录都会先 mkdir -p 创建——预览模式也会建空目录,属预期行为(便于 run 前确认结构)。
命令详解
脚本最终拼接并执行的核心是一条动态 find 命令。以排除 mkv、排除大于 10MB 为例,实际执行等价于:
find "源/子文件夹" -maxdepth 1 ! -path "源/子文件夹" \
\( ! -type f -o \( -type f ! -name "*.mkv" ! -size +10000000c \) \)
各片段含义:
| 片段 | 作用 |
|---|---|
find 目录 -maxdepth 1 |
仅扫描该目录的直接子项,不递归 |
! -path 目录 |
排除目录自身,防止把源目录整个移动 |
\( ... \) |
外层分组,包裹整个匹配条件 |
! -type f -o |
「或」分支一:匹配子目录/链接等非普通文件,这些一律迁移 |
\( -type f … \) |
「或」分支二:普通文件,并在此内叠加排除条件 |
! -name "*.mkv" |
后缀排除(大小写敏感,见注意事项) |
! -size +Nc |
大小排除,N 为换算后的字节数(十进制 MB),+ 表示大于 |
-exec mv {} 目标/ \; |
run 模式下逐文件执行移动 |
其中「排除大小」的实现:交互输入为十进制 MB(如 10.5),由 awk 换算为字节 Nc = N × 1000 × 1000 后交给 find -size +Nc,因此是严格意义的十进制 MB 语义(find 原生的 M 后缀为 MiB=1048576 字节,见注意事项)。
注意事项
- 先 preview 后 run:run 模式不可撤销,目标目录同名文件会被直接覆盖(
mv覆盖语义) - 后缀排除大小为写敏感:
! -name "*.mp4"不会匹配.MP4;需要忽略大小写可改用-iname - 大小排除用字节换算:脚本按十进制(1000×1000)换算,与部分工具按 MiB(1024×1024)的计算存在约 4.9% 差异
>0MB(即>0c)不排除任何文件;若需「移动 0 字节以上」应使用0.000001- 子目录整体迁移时不应用后缀/大小过滤,注意确认目录内是否存在需要保留的内容
- 源根目录下若存在非目录项(散文件),不会在循环中被处理(循环只遍历
*/) - 传给
find的条件均按数组拼接,路径含空格/特殊字符时脚本已整体正确引用 - 传参少于 2 个会报错并退出;交互模式空输入会返回上一级菜单
- 目标建议与源处于同一文件系统内,跨盘迁移时按实际速度校验列表结果
脚本源码
#!/bin/bash
set -uo pipefail
gl_hui='\033[38;5;59m'
gl_hong='\033[38;5;9m'
gl_lv='\033[38;5;10m'
gl_huang='\033[38;5;11m'
gl_lan='\033[38;5;32m'
gl_bai='\033[38;5;15m'
gl_zi='\033[38;5;13m'
gl_bufan='\033[38;5;14m'
log_info() { echo -e "${gl_lan}[信息]${gl_bai} $*"; }
log_ok() { echo -e "${gl_lv}[成功]${gl_bai} $*"; }
log_warn() { echo -e "${gl_huang}[警告]${gl_bai} $*"; }
log_error() { echo -e "${gl_hong}[错误]${gl_bai} $*" >&2; }
break_end() {
echo -e "${gl_lv}操作完成${gl_bai}"
echo -e "${gl_bai}按任意键继续 ${gl_hong}.${gl_huang}.${gl_lv}.${gl_bai}\c"
read -r -n 1 -s -p ""
echo ""
clear
}
sleep_fractional() {
local seconds=$1
if sleep "$seconds" 2>/dev/null; then return 0; fi
if command -v perl >/dev/null 2>&1; then perl -e "select(undef, undef, undef, $seconds)"; return 0; fi
if command -v python3 >/dev/null 2>&1; then python3 -c "import time; time.sleep($seconds)"; return 0; fi
if command -v python >/dev/null 2>&1; then python -c "import time; time.sleep($seconds)"; return 0; fi
local int_seconds=$(echo "$seconds" | awk '{print int($1+0.999)}')
sleep "$int_seconds"
}
cancel_return() {
local menu_name="${1:-退出脚本}"
echo -ne "${gl_lv}即将返回 ${gl_huang}${menu_name} ${gl_hong}.${gl_huang}.${gl_lv}.${gl_bai}\c"
sleep_fractional 0.5
echo -ne "${gl_hong}.${gl_huang}.${gl_lv}.${gl_bai}\c"
sleep_fractional 0.6
echo ""
clear
}
exit_script() {
local frames=("⠋" "⠙" "⠹" "⠸" "⠼" "⠴" "⠦" "⠧" "⠇" "⠏")
local dots=(
"${gl_hong}."
"${gl_huang}."
"${gl_lv}."
"${gl_bufan}."
"${gl_zi}."
)
local dot_buffer=""
local frame_len=${#frames[@]}
local dot_idx=0
local total_dots=${#dots[@]}
for ((i=0; i<20; i++)); do
if (( i > 0 && i % 3 == 0 && dot_idx < total_dots )); then
dot_buffer+=${dots[$dot_idx]}
((dot_idx++))
fi
echo -ne "\r\033[K${gl_bufan}${frames[i % frame_len]}${gl_bai} 正在退出 ${dot_buffer}"
sleep_fractional 0.06
done
echo -e "\r\033[K${gl_lv}✓${gl_bai} 成功退出\n"
clear
exit 0
}
handle_y_n() {
echo -ne "\r${gl_hong}无效的选择,请输入 ${gl_bai}(${gl_lv}y${gl_bai}或${gl_hong}N${gl_bai}) ${gl_hong}.${gl_huang}.${gl_lv}.${gl_bai}\c"
sleep_fractional 0.3
echo -ne "\r${gl_huang}无效的选择,请输入 ${gl_bai}(${gl_lv}y${gl_bai}或${gl_hong}N${gl_bai}) ${gl_hong}.${gl_huang}.${gl_lv}.${gl_bai}\c"
sleep_fractional 0.3
echo -ne "\r${gl_lv}无效的选择,请输入 ${gl_bai}(${gl_lv}y${gl_bai}或${gl_hong}N${gl_bai}) ${gl_hong}.${gl_huang}.${gl_lv}.${gl_bai}\c"
sleep_fractional 0.6
echo ""
return 2
}
exit_animation() {
echo -ne "\r${gl_lv}即将退出 ${gl_hong}.${gl_huang}.${gl_lv}.${gl_bai}\c"
sleep_fractional 0.5
echo -ne "${gl_hong}.${gl_huang}.${gl_lv}.${gl_bai}\c"
sleep_fractional 0.6
echo ""
clear
}
cancel_empty() {
local menu_name="${1:-上一级选单}"
echo -e "${gl_hong}空输入,返回 ${gl_huang}${menu_name} ${gl_hong}.${gl_huang}.${gl_lv}.${gl_bai}\c"
sleep_fractional 0.5
echo -ne "${gl_hong}.${gl_huang}.${gl_lv}.${gl_bai}\c"
sleep_fractional 0.6
echo ""
clear
}
handle_invalid_input() {
echo -ne "\r\033[K${gl_huang}无效的输入,请重新输入! ${gl_zi} 1 ${gl_huang} 秒后返回"
sleep_fractional 1
echo -ne "\r\033[K${gl_lv}无效的输入,请重新输入! ${gl_zi}0${gl_lv} 秒后返回"
sleep_fractional 0.5
echo -ne "\r\033[K"
return 2
}
# ====================== 文件迁移工具 主函数 ======================
video_move() {
# 默认参数
local DEF_EXT=""
local DEF_SIZE=""
local DEF_MODE="preview"
local SRC_ROOT=""
local DST_ROOT=""
local EXCLUDE_EXT="$DEF_EXT"
local EXCLUDE_SIZE="$DEF_SIZE"
local RUN_MODE="$DEF_MODE"
# 判断是否传参
if [ $# -ge 1 ]; then
# 传参模式:必须传入源目录、目标目录,后面3个参数可选
if [ $# -lt 2 ]; then
log_error "传参模式必须至少传入【源目录】【目标目录】两个参数!"
log_info "用法示例:"
log_info "$0 /vol/src /vol/dst"
log_info "$0 /vol/src /vol/dst mp4 10 preview"
exit_script
fi
SRC_ROOT="$1"
DST_ROOT="$2"
[ $# -ge 3 ] && EXCLUDE_EXT="$3"
[ $# -ge 4 ] && EXCLUDE_SIZE="$4"
[ $# -ge 5 ] && RUN_MODE="$5"
else
# ========== 交互式输入 ==========
echo -e "${gl_zi}>>> 视频资源迁移工具${gl_bai}"
echo -e "${gl_bufan}————————————————————————————————————————————————${gl_bai}"
read -r -e -p "$(echo -e "${gl_bai}输入源根目录:")" TMP_SRC
if [ -z "${TMP_SRC}" ]; then
cancel_empty "交互菜单"
return
fi
SRC_ROOT="${TMP_SRC}"
read -r -e -p "$(echo -e "${gl_bai}输入目标根目录:")" TMP_DST
if [ -z "${TMP_DST}" ]; then
cancel_empty "交互菜单"
return
fi
DST_ROOT="${TMP_DST}"
read -r -e -p "$(echo -e "${gl_bai}输入要排除的文件后缀(多个逗号分隔,例 mp4,mkv;回车=不排除):")" TMP_EXT
[ -n "$TMP_EXT" ] && EXCLUDE_EXT="$TMP_EXT"
read -r -e -p "$(echo -e "${gl_bai}大于多少MB的文件需要排除(十进制MB,回车=不按大小排除):")" TMP_SIZE
[ -n "$TMP_SIZE" ] && EXCLUDE_SIZE="$TMP_SIZE"
read -r -e -p "$(echo -e "${gl_bai}模式选择:${gl_lv}preview${gl_bai}预览 / ${gl_hong}run${gl_bai}执行移动【回车默认preview】:")" TMP_MODE
[ -n "$TMP_MODE" ] && RUN_MODE="$TMP_MODE"
fi
echo -e "\n${gl_bufan}==================== 配置汇总 ====================${gl_bai}"
log_info "源 目 录 : ${gl_lan}${SRC_ROOT}${gl_bai}"
log_info "目标目录 : ${gl_lan}${DST_ROOT}${gl_bai}"
log_info "排除后缀 : ${gl_huang}${EXCLUDE_EXT:-无}${gl_bai}"
log_info "排除大于 : ${gl_huang}${EXCLUDE_SIZE:-不限制}${gl_bai} MB(十进制)"
log_info "运行模式 : ${gl_zi}${RUN_MODE}${gl_bai}"
echo -e "${gl_bufan}==================================================${gl_bai}"
read -r -p "$(echo -e "${gl_bai}确认继续?(${gl_lv}y${gl_bai}/${gl_hong}N${gl_bai}) ")" CONFIRM
if [[ ! "${CONFIRM,,}" =~ ^y ]]; then
log_warn "已取消任务,退出"
exit_script
fi
# 循环遍历一级子文件夹
for SRC_DIR in "${SRC_ROOT}"/*/; do
# 取出文件夹名称
DIR_NAME=$(basename "${SRC_DIR%/}")
DST_DIR="${DST_ROOT}/${DIR_NAME}"
echo -e "\n${gl_bufan}=====================================${gl_bai}"
log_info "处理目录: ${gl_zi}${DIR_NAME}${gl_bai}"
log_info "源 目 录: ${gl_lan}${SRC_DIR}${gl_bai}"
log_info "目标目录: ${gl_lan}${DST_DIR}${gl_bai}"
mkdir -p "${DST_DIR}"
echo -e "\n${gl_huang}【待处理列表】${gl_bai}"
CMD=(find "${SRC_DIR}" -maxdepth 1 ! -path "${SRC_DIR}" \( ! -type f -o \( -type f )
# 拼接后缀排除
if [ -n "$EXCLUDE_EXT" ]; then
IFS=',' read -ra EXT_ARR <<< "$EXCLUDE_EXT"
for ext in "${EXT_ARR[@]}"; do
CMD+=(! -name "*.$ext")
done
fi
# 拼接大小条件,MB十进制
if [ -n "$EXCLUDE_SIZE" ]; then
local SIZE_BYTES
SIZE_BYTES=$(awk -v s="$EXCLUDE_SIZE" 'BEGIN{printf "%d", s*1000*1000+0.5}')
CMD+=(! -size +"${SIZE_BYTES}"c)
fi
CMD+=(\) \))
# 打印列表
"${CMD[@]}"
if [ "${RUN_MODE}" = "run" ]; then
echo -e "\n${gl_hong}>>> 执行移动 <<<${gl_bai}"
"${CMD[@]}" -exec mv {} "${DST_DIR}/" \;
fi
log_ok "${DIR_NAME} 处理完成"
done
echo -e "\n${gl_bufan}————————————————————————————————————————————————${gl_bai}"
log_ok "🎉 全部任务结束"
break_end
}
# 入口:检测传参
if [ $# -ge 1 ]; then
video_move "$@"
else
video_move
fi
创建本地脚本
new_script="batch_mv_subdir.sh"
cat > "$new_script" << 'EOF'
#!/bin/bash
# 粘贴上方脚本源码
EOF
# 保留本地脚本,去掉 rm -f "$new_script"
chmod +x "$new_script" && ./"$new_script" && rm -f "$new_script"