随机
Enter 搜索 ↑↓ 切换 Esc 清空

batch_mv_subdir

脚本

目录文件迁移/移动工具

补充说明

该脚本用于将源根目录下「每个一级子文件夹」中的文件,整体迁移到目标根目录下「同名子文件夹」中,保持原有目录结构不变。遍历源目录的子文件夹时自动在目标目录 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

运行环境

无需安装 ffmpeg,不涉及任何多媒体编解码。

功能特点

参数说明

参数 含义 默认值 示例
$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 字节,见注意事项)。

注意事项

脚本源码

#!/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"

相关命令