本文首次发表于:链滴社区 (ld246.com)
本文采用:CC BY-SA 4.0
本文及所附脚本为作者个人在研究“如何在 Termux 中顺利访问
/storage/emulated/0/Android/data/
”过程中所作的技术记录与尝试,仅供技术学习与交流之用。请用户自行评估脚本的适用性与安全性,并充分了解其可能对系统、文件或权限造成的影响。若您选择运行或修改该脚本,即表示您已理解并接受由此可能引发的一切后果,包括但不限于数据丢失、系统异常或其他技术问题。
作者不对脚本的功能完整性、稳定性或安全性作出任何明示或暗示的保证。因脚本本身缺陷、用户误操作、修改或不当使用所造成的任何损失或法律责任,均由使用者自行承担,与作者无关。
请勿将本文内容或相关脚本用于任何非法用途,否则后果自负。
背景
需求来源
在某社区偶然间翻到了一篇有在安卓上使用 FFmpeg 需求的帖子。大致情况就是说,希望能在像 MT 管理器这种不仅有很多实用功能而且还自带终端模拟器的多功能工具上便捷地对视频进行转码之类的操作。比如直接在文件管理中把 B 站的音视频缓存文件给合并到一起去。
我当时也不是很在意这个东西,想着手机上至少有 Termux,以及可能知道的人不是很多的一款半图形化工具 FFmpeg Media Encoder(com.silentlexx.ffmpeggui
)。一般把这俩搭配起来使用,在手机上就能满足日常需求了。要解决的仅仅是要打通前往 Android/data
目录的壁垒。
不试不知道,一试吓一跳。这看似简单的一个“小问题”暗藏了太多玄机。
寻找官方方案
首先我想到的是,Termux 官方应该会有提供请求 Android/data
目录的方法,但貌似是我想多了。比如 ES 文件浏览器、MT 管理器等至少可以从系统文档逐个请求每个应用的 Android/data
目录,进而实现对整个 Android/data
目录的访问。但在 Termux 这边貌似找不到任何直接有效的授权入口。
寻找 ADB 方案
网上找了一圈也没找到官方一点的方案,于是就想着:那靠 ADB 总该可以了吧。但靠 ADB 就应该能直接用 Shikuku,还不如直接靠 Shizuku 授权来得方便。于是搜啊,搜着搜着这不,rish 就摆到我面前来了。
简单来说呢,就是有一对来自 Shizuku 官方的 shell 脚本 + dex,把其中的 shell 脚本(rish)自行稍作修改,就可以当成一个某应用专属的提权插件来使用了,充当 Shizuku 与其他终端模拟器之间的桥梁。反正我是这么理解的。这 dex 我也不知道叫啥,我也不是搞安卓开发的,对这方便不是特别了解。
rish 貌似可行
试了下之后发现,欸,上手还是比较简单的,填个包名、授予执行权限,然后直接用就是了。就进了那个那个 ADB 里面了嘛。只不过呢,嗯,连接到 rish 的那一刻啊,得确保 Shizuku 在运行,不仅仅是服务在运行,Shizuku APP 也必须得活着,不然连不上。连上之后问题就不大了,Shizuku APP 基本可以滚一边去了。只不过要是频繁切过来切过去的话,还是有点老火。
环境隔离的现实
接下来就研究怎么继续打开这个“通道”呗,首先在 ADB 里面确实是可以访问到 Android/data
目录的,这点没有什么问题。那问题在哪呢,正当我打算带上 FFmpeg 一起去串门的时候啊,问题就出现了,FFmpeg 没了,诶呀,没了,这就糟心了,ADB 用的少没想到这环境还是隔离的,这可就给我整无语了呀,这还打通个毛线。呵!果然还是我读的书少太傻太天真了呀。
哎哟我去,这给我整的。有 FFmpeg 的时候用不了 ADB 权限,有 ADB 权限的时候又用不了 ADB;Termux 访问不了
Android/data
,外部程序包括 ADB 在内又无法访问 Termux 私有目录(主目录除外)。这这这,这是在拼命教会我“鱼与熊掌不可得兼”呀。你以为的你以为的就是你以为的。
继续开干
不过嘛,来都来了,不干一场怎么能快活呢,万一我哪天真要这么用可该咋整?于是乎,问必应、问 AI,这里拼、那里凑,总算还是找到了一些解决思路。
方案概览
文件中转
这一点倒是不用多想,毕竟手机内部存储还是能够公开访问的,中转一下倒是不难,关键在于二者如何无缝完美地衔接。
ns 网络传输
这从 AI 那里问来的,呃还行吧。除了除了速度慢了点、失败率高之外其实也还可以凑合。至少借助这玩意可以在不产生临时文件的情况下完成文件的传输。使用示例:
需确保软件包
netcat-openbsd
已安装
# 例如,要将 /storage/emulated/0/Android/data/com.xxx/ 中的 test-input.mp3 文件传输到 Termux 主目录下,存为 test-output.mp3。
# 可以在两个进程中分开执行:
nc -l 12345 > ~/test-output.mp3
rish -c "nc -q 1 127.0.0.1 12345 < /storage/emulated/0/Android/data/com.xxx/test-input.mp3"
# 合并为一行执行:
nohup nc -l 12345 > ~/test-output.mp3 & rish -c "nc -q 1 127.0.0.1 12345 < /storage/emulated/0/Android/data/com.xxx/test-input.mp3"
这玩意还是有点意思的,就是太不稳定了,不好把控。
一些其他的路子
也有考虑过其他方案,比如自己编译 FFmpeg 啥的,但编译这玩意老费劲了,还是不搞了,编译几个模块等下搞得头都大了。另外还有一些什么别的野路子,貌似也不咋管用,就不一一展开说了。
最终选择的方案
综合考虑,最终还是选择了文件中转的方案,毕竟文件复制操作几乎可以 100% 成功,也容易理解。关键点就在于如何把 rish 跟 Termux 的关系打好了,这俩货色真的是,哎服了。本来也就只想搞搞玩玩,没想到它直接给我放大招,给我来真的,诶呀渍渍渍。
细节还原
好了,现在就以“如何让 Termux 中的 FFmpeg 与 B 站顺利牵手”为课题,逐步展开。
第一步,解析 B 站缓存文件路径结构
这或许是我能想到的第一件事了。B 站的视频缓存大多为 mp4,只不过音视频各自分开解耦了而已。虽然重要的也就俩文件,一个 video.m4s
和一个 audio.m4s
,把它俩用 FFmpeg 流复制一下直接合并就行,但为了能够制作出一款真正实用的视频导出工具,了解一下 B 站缓存路径的目录结构还是有必要的。按个人习惯的命名方式,其结构大概就是这个样子:
/storage/emulated/0/Android/data/tv.danmaku.bili └─ download ├─ proj1 │ ├─ part1 │ │ ├─ 120 │ │ │ ├─ audio.m4s │ │ │ └─ video.m4s │ │ └─ entry.json │ └─ part2 │ ├─ 112 │ │ ├─ audio.m4s │ │ └─ video.m4s │ └─ entry.json ├─ proj2 │ └─ part1 │ ├─ 64 │ │ ├─ audio.m4s │ │ └─ video.m4s │ └─ entry.json └─ proj3
设想是可以整一个程序,使其接受以下输入都能正常运作:
/storage/emulated/0/Android/data/tv.danmaku.bili/download/proj1/part1/120/video.m4s
:导出当前目录下的音视频(深度为 0)/storage/emulated/0/Android/data/tv.danmaku.bili/download/proj1/part1/120/audio.m4s
:导出当前目录下的音视频(深度为 0)/storage/emulated/0/Android/data/tv.danmaku.bili/download/proj1/part1/entry.json
:导出一级子文件夹下的音视频(深度为 1)/storage/emulated/0/Android/data/tv.danmaku.bili/download/proj1/part1/120/
:导出当前目录下的音视频(深度为 0)/storage/emulated/0/Android/data/tv.danmaku.bili/download/proj1/part1/
:导出一级子文件夹下的音视频(一个视频项目中的单集,深度为 1)/storage/emulated/0/Android/data/tv.danmaku.bili/download/proj1/
:导出二级子文件夹下的音视频(一整个项目,深度为 2)/storage/emulated/0/Android/data/tv.danmaku.bili/download/
:导出 3 级子文件夹下的音视频(所有,深度为 3)
第二步,编码实现自动判断用户输入的路径的“深度”
一开始还想把大部分任务都丢给 AI 的,但不知是我表述不够清楚还是咋滴,AI 老是把我的问题给复杂化,编出来的代码也是一堆的 bug,后面只好自己动手了。虽然有不懂的地方还是得多问 AI、多查资料。
主要思路:
- 找出符合条件的
video.m4s
,判断其距离用户输入的路径有多“深”,进而得出走到用户输入出自己处于一个什么样的位置。 - 知道自己处于什么位置,就知道该去从哪里导出什么东西了。
大致逻辑如下:
# 查找特定深度的文件
function find_file_at_depth() {
local file_name="$1"
local base_dir="$2"
local target_depth="$3"
# 个人将“当前目录”深度视为0,但find命令将其视为1,因此需要协调一下
local found_path=$(find "$base_dir" -mindepth "$((target_depth+1))" -maxdepth "$((target_depth+1))" -name "$file_name" | head -n 1)
if [[ -n "$found_path" ]]; then
FILE_PATH="$found_path"
PATH_DEPTH="$target_depth"
return 0
else
return 1
fi
}
# 获取路径深度
function get_path_depth() {
local file_name="$1"
local base_dir="$2" # 基础目录
local max_depth="$3" # 最大查找深度,0表示仅当前目录
local depths="" # 深度数组
FILE_PATH="" # 用于保存文件路径
PATH_DEPTH="" # 用于保存路径深度
# 检查基础目录是否存在
if [[ ! -d "$base_dir" ]]; then
echo "错误:基础目录 '$base_dir' 不存在"
exit 1
fi
# 生成深度数组:0 1 2 3
depths=($( seq 0 $max_depth ))
# 按深度顺序查找文件
echo ""
echo "将按如下深度进行查找:${depths[@]}"
for depth in "${depths[@]}"; do
if find_file_at_depth "$file_name" "$base_dir" "$depth"; then
# echo "发现深度为 $depth 的文件:$FILE_PATH"
break
fi
done
# 输出结果
echo "--------------------------------"
if [[ -n "$FILE_PATH" ]]; then
echo "已找到文件。"
echo "深度: $PATH_DEPTH"
echo "位置: $FILE_PATH"
else
echo "未找到文件。"
fi
echo "--------------------------------"
}
get_path_depth "$VIDEO_FILENAME" "$BASE_DIR" "$MAX_DEPTH"
fi
echo ""
# 根据路径深度,执行不同行为
# “仓库”级别深度的处理
if [[ "$PATH_DEPTH" == "3" ]]; then
PART_DIR=""
PROJECT_DIR=""
PROJECTS_DIR="$BASE_DIR"
handle_project_batch "$PROJECTS_DIR"
# “项目”级别深度的处理
elif [[ "$PATH_DEPTH" == "2" ]]; then
PART_DIR=""
PROJECT_DIR="$BASE_DIR"
PROJECTS_DIR=$(dirname "$PROJECT_DIR")
handle_project "$PROJECT_DIR"
# “章节”级别深度的处理
elif [[ "$PATH_DEPTH" == "1" ]]; then
PART_DIR="$BASE_DIR"
PROJECT_DIR=$(dirname "$PART_DIR")
PROJECTS_DIR=$(dirname "$PROJECT_DIR")
handle_part "$PART_DIR" "$OUTPUT_DIR" "1"
# “章节内容”级别深度的处理
elif [[ "$PATH_DEPTH" == "0" ]]; then
PART_DIR=$(dirname "$BASE_DIR")
PROJECT_DIR=$(dirname $PART_DIR)
PROJECTS_DIR=$(dirname "$PROJECT_DIR")
handle_part "$PART_DIR" "$OUTPUT_DIR" "1"
fi
echo "操作结束。"
第三步,根据路径深度模拟导出相应的内容
主要逻辑大致如下:
# 处理章节
function handle_part() {
local part_dir="$1"
local part_name=$(basename "$part_dir")
local project_name=$(basename "$(dirname "$part_dir")")
local output_file="$2/$part_name"
local is_echo_enabled="$3"
# 根据第3个参数是否存在决定是否打印详情
[[ -n "$is_echo_enabled" ]] && echo "处理章节:$project_name/$part_name"
# 检测JSON文件路径的有效性
local FILE_JSON="$part_dir/$JSON_FILENAME"
if [[ ! -f "$FILE_JSON" ]]; then
FILE_JSON=""
fi
# 查找VIDEO文件
local FILE_VIDEO=""
if find_file_at_depth "$VIDEO_FILENAME" "$part_dir" "1"; then
FILE_VIDEO="$FILE_PATH"
fi
# 查找AUDIO文件
local FILE_AUDIO=""
if find_file_at_depth "$AUDIO_FILENAME" "$part_dir" "1"; then
FILE_AUDIO="$FILE_PATH"
fi
# 如已存在则跳过
[[ -f "$output_file" && ! -e "${output_file}.merging" ]] && echo "跳过 '$(basename "$output_file")':已存在。" && return 2
# 创建进行中标记
echo "" > "${output_file}.merging"
# 模拟导出
echo "PART_DIR=$part_dir" > "$output_file"
echo "FILE_JSON=$FILE_JSON" >> "$output_file"
echo "FILE_VIDEO=$FILE_VIDEO" >> "$output_file"
echo "FILE_AUDIO=$FILE_AUDIO" >> "$output_file"
# 删除进行中标记
rm -f "${output_file}.merging"
}
# 处理项目
function handle_project() {
local project_dir="$1"
local progress="$2"
local project_name=$(basename "$project_dir")
local output_dir="$OUTPUT_DIR/$project_name"
# 如已存在则跳过
[[ -e "$output_dir" && ! -e "${output_dir}.merging" ]] && echo "跳过 '$(basename $output_dir)':已存在。" && return 2
# 创建进行中标记
echo "" > "${output_dir}.merging"
# 计算章节数量
local part_count=$(find "$project_dir" -mindepth "$((2+1))" -maxdepth "$((2+1))" -type f -name "$VIDEO_FILENAME" -printf '.' | wc -c)
echo "****$progress 项目名称:$project_name 项目章节数量:$part_count"
if [[ "$part_count" -gt 1 ]]; then
# echo 处理项目各章节。
# 将各章节内容保存在以项目名称命名的文件夹中
mkdir -p "$output_dir"
for part_dir in "$project_dir"/*; do
if [[ -d "$part_dir" ]]; then
handle_part "$part_dir" "$output_dir" "1"
fi
done
else
# echo 项目只包含单个内容,按单内容处理。
local part_dir=$(find "$project_dir" -mindepth "$((0+1))" -maxdepth "$((0+1))" -type d -name "*")
handle_part "$part_dir" "$OUTPUT_DIR"
fi
# 删除进行中标记
rm -f "${output_dir}.merging"
}
# 处理仓库(所有项目)
function handle_project_batch() {
local projects_dir="$1"
local project_count=$(find "$projects_dir" -mindepth "$((0+1))" -maxdepth "$((0+1))" -type d -name "*" -printf '.' | wc -c)
local part_count=$(find "$projects_dir" -mindepth "$((1+1))" -maxdepth "$((1+1))" -type d -name "*" -printf '.' | wc -c)
# 导出全部项目前手动进行确认
echo "共 $project_count 个项目,含 $part_count 条内容,确定要全部导出? [y/N]"
REPLY="N"
read REPLY
if [[ "$REPLY" == "Y" || "$REPLY" == "y" ]]; then
echo "导出 $project_count 个项目..."
# 初始化进度数据
local num=0
local progress=""
for project_dir in "$projects_dir"/*; do
if [[ -d "$project_dir" ]]; then
# 将进度信息传递给项目处理函数,将其打印出来,便于掌握进度情况
num=$((num+1))
progress=" $((num*100/project_count))% ($num/$project_count)"
handle_project "$project_dir" "$progress"
fi
done
else
echo "你取消了操作。"
fi
}
第四步,与 Termux 进行对接,双方正式牵手
由于 JSON 文件中存放了视频信息,将其内容一并导出是一个不错的选择。
由于文件的发送要在 rish 端处理,内容的合并要在 Termux 端处理,东西多太多太大的话直接一下子全部导出来肯定是不现实的。于是就一个项目一个项目的来。另外,Termux 里面可以很容易开启 rish,反之则貌似行不通。所以,无论如何都得保住 Termux 的“可用性”,一切以它为主。
最后想了一下,干脆直接先通过 rish,让它先把“要做什么”全都搞清楚记录下来,后面 Termux 直接照着做就是。
于是直接在 rish 中为每个需要处理的视频都制作一份专属的“合成脚本”,Termux 再去一个个调用这些脚本来完成所需任务。“合成脚本”先通过 rish 把视频临时复制出来,而后继续在 Termux 中通过 FFmpeg 来进行合并,最后删除所有的临时文件。
这样,通过 Termux 与 rish 相互配合、衔接,便实现了 Termux 下的 FFmpeg 对于 Android/data
目录的间接访问,而无需完全离开 Termux 环境,保证了“鱼,我所欲也;熊掌,亦我所欲也”。
# 处理章节
function handle_part() {
local part_dir="$1"
local part_name=$(basename "$part_dir")
local project_name=$(basename "$(dirname "$part_dir")")
local output_file="$2/merge-script_$part_name.sh"
local is_echo_enabled="$3"
# 根据第3个参数是否存在决定是否打印详情
[[ -n "$is_echo_enabled" ]] && echo "处理章节:$project_name/$part_name"
# 检测JSON文件路径的有效性
local FILE_JSON="$part_dir/$JSON_FILENAME"
if [[ ! -f "$FILE_JSON" ]]; then
FILE_JSON=""
fi
# 查找VIDEO文件
local FILE_VIDEO=""
if find_file_at_depth "$VIDEO_FILENAME" "$part_dir" "1"; then
FILE_VIDEO="$FILE_PATH"
fi
# 查找AUDIO文件
local FILE_AUDIO=""
if find_file_at_depth "$AUDIO_FILENAME" "$part_dir" "1"; then
FILE_AUDIO="$FILE_PATH"
fi
# 如已存在则跳过
[[ -f "$output_file" && ! -e "${output_file}.merging" ]] && echo "跳过 '$(basename "$output_file")':已存在。" && return 2
# 创建进行中标记
echo "" > "${output_file}.merging"
# 导出
cat<<EOF >> "$output_file"
#!/bin/bash
# 定义源文件路径
PART_DIR="$part_dir"
FILE_JSON="$FILE_JSON"
FILE_VIDEO="$FILE_VIDEO"
FILE_AUDIO="$FILE_AUDIO"
# 定义是否包含多集
IS_MULTI_PART="$3"
# 定义输出文件夹
OUTPUT_DIR="$OUTPUT_DIR"
EOF
cat<<'EOF' >> "$output_file"
# 定义输出文件名缺省值
DEFAULT_VIDEO_NAME=$(date +"%Y-%m-%d_%H-%M-%S-%3N")
# 定义临时文件路径
TEMP_DIR="$OUTPUT_DIR/.temp"
TEMP_VIDEO="$TEMP_DIR/video"
TEMP_AUDIO="$TEMP_DIR/audio"
TEMP_JSON="$TEMP_DIR/json"
# 创建临时文件夹
[[ ! -e "$TEMP_DIR" ]] && mkdir -p "$TEMP_DIR"
echo ""
# 通过rish将源文件复制到临时文件夹
until rish -c "cp '$FILE_JSON' '$TEMP_JSON'; cp '$FILE_VIDEO' '$TEMP_VIDEO'; cp '$FILE_AUDIO' '$TEMP_AUDIO';"; do
echo "貌似无法连接到Shizuku,重试中..."
echo "请确保Shizuku服务及APP均处于运行状态。"
sleep 1
done
# 验证JSON键值的有效性
function is_key_valid() {
local key_name="$1"
local json_file="$2"
KEY_VALUE=$(jq -r "${key_name}" "$json_file")
# echo "键值:$KEY_VALUE"
if [[ "$KEY_VALUE" != "null" && -n "$KEY_VALUE" ]]; then
return 0
else
return 1
fi
}
# 检查JSON文件是否合法
if [[ -f "$TEMP_JSON" ]] && (jq -e . "$TEMP_JSON" >/dev/null 2>&1); then
# echo "我说 OK-OK. Good 佛祖 for everyone!"
VIDEO_NAME=""
is_key_valid ".owner_name" "$TEMP_JSON" && VIDEO_NAME+="@$KEY_VALUE" # 作者
is_key_valid ".title" "$TEMP_JSON" && VIDEO_NAME+="_$KEY_VALUE" # 标题
is_key_valid ".quality_pithy_description" "$TEMP_JSON" && VIDEO_NAME+="_$KEY_VALUE" # 清晰度
is_key_valid ".page_data.page" "$TEMP_JSON" && PART_NAME="P$KEY_VALUE" # && VIDEO_NAME+="_$KEY_VALUE" # 章节号
is_key_valid ".page_data.part" "$TEMP_JSON" && PART_NAME+="-$KEY_VALUE" # && VIDEO_NAME+="-$KEY_VALUE" # 章节标题
# 若键值均无效则使用当前时间戳作为文件名
if [ ! -n "$VIDEO_NAME" ]; then
VIDEO_NAME=$DEFAULT_VIDEO_NAME
fi
else
# echo "警告:找不到视频信息!"
# echo "佛祖说不行,只能 七十二万!"
# 若JSON文件非法则使用当前时间戳作为文件名
VIDEO_NAME=$DEFAULT_VIDEO_NAME
fi
# 多集视频的情况需要对输出路径做出相应调整
if [[ -n "$IS_MULTI_PART" ]]; then
# OUTPUT_DIR="$OUTPUT_DIR/$VIDEO_NAME"
# [[ ! -e "$OUTPUT_DIR" ]] && mkdir "$OUTPUT_DIR"
# VIDEO_NAME="$PART_NAME"
[[ ! -e "$OUTPUT_DIR/$VIDEO_NAME" ]] && mkdir "$OUTPUT_DIR/$VIDEO_NAME"
VIDEO_NAME="$VIDEO_NAME/$PART_NAME"
fi
# 如果已存在则跳过,否则创建“合并中”标记
[[ -f "$OUTPUT_DIR/$VIDEO_NAME.mp4" && ! -e "$OUTPUT_DIR/$VIDEO_NAME.mp4.merging" ]] && { echo "跳过 '$OUTPUT_DIR/$VIDEO_NAME.mp4':已存在"; } && rm -rf "$TEMP_DIR" && rm $0 && exit
echo "" > "$OUTPUT_DIR/$VIDEO_NAME.mp4.merging"
# 合并视频并删除临时文件
echo "合并视频文件..."
ffmpeg -hide_banner -y -i "$TEMP_VIDEO" -i "$TEMP_AUDIO" -c copy "$OUTPUT_DIR/$VIDEO_NAME.mp4" >/dev/null 2>&1 && { echo "成功!已经保存到:'$VIDEO_NAME.mp4'"; } || { echo "合并过程中出现问题。"; }
echo ""
echo "清理临时数据..."
rm -rf "$TEMP_DIR" && rm "$OUTPUT_DIR/$VIDEO_NAME.mp4.merging" && rm "$0" && { [[ -n "$IS_MULTI_PART" && -z "$(ls -A "$(dirname "$0")")" ]] && rm -r "$(dirname "$0")"; } && exit
EOF
# 删除进行中标记
rm -f "${output_file}.merging"
}
完整实现代码
使用说明
先决条件
- 需要确保
ffmpeg
、jq
等软件已经正确安装到 Termux 中 - 需要确保
rish
可用且正确为其配置了PATH
环境变量,Shizuku 处于打开状态 - 已经根据需要自行修改脚本的
OUTPUT_DIR
变量值(约 39 行处),即视频输出路径,默认/storage/emulated/0/DCIM/Bilibili
。
调用方式
假设当前脚本的完整路径为 /path/to/bili-merge.sh
,那么如下调用方式都是可行的:
# 直接通过bash运行,运行后再询问要处理视频的路径
bash /path/to/bili-merge.sh
# 通过bash运行,并传入视频位置
# 具体文件
bash /path/to/bili-merge.sh /storage/emulated/0/Android/data/tv.danmaku.bili/download/113503100737784/c_26822839899/64/video.m4s
bash /path/to/bili-merge.sh /storage/emulated/0/Android/data/tv.danmaku.bili/download/113503100737784/c_26822839899/64/audio.m4s
bash /path/to/bili-merge.sh /storage/emulated/0/Android/data/tv.danmaku.bili/download/113503100737784/c_26822839899/entry.json
# 具体文件夹
bash /path/to/bili-merge.sh /storage/emulated/0/Android/data/tv.danmaku.bili/download/113503100737784/c_26822839899/64/
bash /path/to/bili-merge.sh /storage/emulated/0/Android/data/tv.danmaku.bili/download/113503100737784/c_26822839899/
bash /path/to/bili-merge.sh /storage/emulated/0/Android/data/tv.danmaku.bili/download/113503100737784/
bash /path/to/bili-merge.sh /storage/emulated/0/Android/data/tv.danmaku.bili/download/
# AV号
bash /path/to/bili-merge.sh 113503100737784
# 导出所有
bash /path/to/bili-merge.sh all
执行过程
脚本大致分为两个阶段,通过判断 ffmpeg
的可用性决定进入哪个阶段:
-
首先直接在 Termux 中运行脚本
-
由于
ffmpeg
已经安装,调用rish
执行一次本脚本-
在
rish
中执行,由于ffmpeg
不存在,直接进入【第一阶段】。 -
首先会询问用户要处理的视频的所在路径(也可以在执行脚本时直接通过参数传递路径信息)
一般来讲,输入视频所在文件夹即可。
另外,输入 AV 号(纯数字)也是可以的,因为视频项目文件夹以 AV 号命名 -
定位到相关路径,根据路径的“深度”决定要处理“一集、一整套视频、还是已缓存的所有视频”
-
为需要处理的每个视频每一集,生成一个“导出脚本”,放置在“输出路径”下
-
【第一阶段】结束
-
-
继续在 Termux 中执行【第二阶段】
-
第二阶段其实没什么东西,就是挨个执行第一阶段中生成的那些“导出脚本”
脚本内容大致为:
- 通过
rish
把当前要处理的视频从 bilibili 的私有缓存目录中,复制到临时目录 - 通过
jq
解析视频的entry.json
文件,得到视频的标题、作者、清晰度等信息 - 通过
ffmpeg
合并video.m4s
、audio.m4s
,导出为@作者_视频标题_清晰度.mp4
,或者对于多集视频,导出为@作者_视频标题_清晰度/章节号-章节标题.mp4
- 删除所有临时文件,以及“导出脚本”本身
- 通过
-
【第二阶段】结束
实现代码
完整实现如下,可能会有一些小 bug,但总体来说问题不大。
#!/bin/bash
# B站缓存智能导出工具
# @Updated 2025-05-07 21:09
# @Created 2025-05-06 15:31
# @Author Are You OK
# @Version 1.0-beta
# ================================基本功能及配置部分================================
# 路径标准化
function get_available_path() {
local path="$1"
local base_dir="$2"
# 使用 eval 来去除首尾空格并扩展 ~ 和环境变量
path=$(eval echo "$path")
# 若输入all则视同处理全部
[[ "$path" == "all" ]] && path="$BILI_MAIN_DIR"
# 转换为绝对路径
path=$(realpath "${path}" 2>/dev/null || readlink -f "${path}")
# 如果路径不存在则返回错误代码
[[ ! -e "$path" ]] && path=$(realpath "${base_dir}/${path}" 2>/dev/null || readlink -f "${base_dir}/${path}")
[[ ! -e "$path" ]] && return 1
# 如果是目录并且末尾没有斜杠,则加上斜杠
# if [[ -d "$path" ]] && [[ "${path: -1}" != "/" ]]; then
# path="${path}/"
# fi
echo "$path"
return 0
}
# 配置项
BILI_MAIN_DIR=$(get_available_path "/storage/emulated/0/Android/data/tv.danmaku.bili/download/")
OUTPUT_DIR=$(get_available_path "/storage/emulated/0/DCIM/Bilibili")
JSON_FILENAME="entry.json"
VIDEO_FILENAME="video.m4s"
AUDIO_FILENAME="audio.m4s"
MAX_DEPTH=3 # 最大查找深度,0表示仅当前目录
# ================================环境检测部分================================
# Termux环境:通过rish进入第一阶段,然后进入第二阶段
# 非Termux环境:进入第一阶段
# if [[ "$HOME" != "/" ]]; then
echo ""
$(ffmpeg --help >/dev/null 2>&1) && {
echo "【首次访问Termux环境,调用rish】";
# rish -c "sh $0 $1";
until rish -c "sh $0 $1"; do
echo "貌似无法连接到Shizuku,重试中..."
echo "请确保Shizuku服务及APP均处于运行状态。"
sleep 1
done
echo ""
echo "【回到Termux环境,进入第二阶段】";
found_scripts=$(find "$OUTPUT_DIR" -mindepth "$((0+1))" -maxdepth "$((1+1))" -type f -name "merge-script_*.sh")
found_scripts=($found_scripts)
for script in "${found_scripts[@]}"; do
# echo "script=$script"
bash "$script"
done
echo "【第二阶段结束】"
exit
} || {
echo "【访问非Termux环境,进入第一阶段】";
}
# 检查参数个数,如未传入基础目录则索要
if [[ $# -ne 1 ]]; then
echo "输入待处理视频的路径:"
REPLY=""
read REPLY
INPUT_TARGET=$(get_available_path "$REPLY" "$BILI_MAIN_DIR") # 根据输入得到的基础目录
# echo "用法:$0 BASE_DIRectory"
# exit 1
else
INPUT_TARGET=$(get_available_path "$1" "$BILI_MAIN_DIR") # 根据参数得到的基础目录
fi
# 检查基础目录是否可用
BASE_DIR="$INPUT_TARGET"
if [[ -f "$INPUT_TARGET" ]]; then
local filename=$(basename "$BASE_DIR")
if [[ "$filename" == "$VIDEO_FILENAME" || "$filename" == "$AUDIO_FILENAME" ]]; then
PATH_DEPTH="0"
BASE_DIR=$(dirname "$BASE_DIR")
elif [[ "$filename" == "$JSON_FILENAME" ]]; then
PATH_DEPTH="1"
BASE_DIR=$(dirname "$BASE_DIR")
fi
elif [[ ! -d "$INPUT_TARGET" ]]; then
echo "错误:'$REPLY$1' 不可达"
exit 1
elif [[ "$INPUT_TARGET" == "/" ]]; then
echo "错误:'$REPLY$1' 不是有效的视频项目路径"
exit 1
fi
# ================================函数定义部分================================
# 查找特定深度的文件
function find_file_at_depth() {
local file_name="$1"
local base_dir="$2"
local target_depth="$3"
# 个人将“当前目录”深度视为0,但find命令将其视为1,因此需要协调一下
local found_path=$(find "$base_dir" -mindepth "$((target_depth+1))" -maxdepth "$((target_depth+1))" -name "$file_name" | head -n 1)
if [[ -n "$found_path" ]]; then
FILE_PATH="$found_path"
PATH_DEPTH="$target_depth"
return 0
else
return 1
fi
}
# 获取路径深度
function get_path_depth() {
local file_name="$1" # 查找的文件名
local base_dir="$2" # 基础目录
local max_depth="$3" # 最大查找深度,0表示仅当前目录
local depths="" # 深度数组
FILE_PATH="" # 用于保存文件路径
PATH_DEPTH="" # 用于保存路径深度
# 检查基础目录是否存在
if [[ ! -d "$base_dir" ]]; then
echo "错误:基础目录 '$base_dir' 不存在"
exit 1
fi
# 生成待测深度数组:0 1 2 3
depths=($( seq 0 $max_depth ))
# 按深度顺序查找文件
echo ""
echo "将按如下深度进行查找:${depths[@]}"
for depth in "${depths[@]}"; do
if find_file_at_depth "$file_name" "$base_dir" "$depth"; then
# echo "发现深度为 $depth 的文件:$FILE_PATH"
break
fi
done
# 输出结果
echo "--------------------------------"
if [[ -n "$FILE_PATH" ]]; then
echo "找到文件,已确认深度。"
echo "深度: $PATH_DEPTH"
echo "位置: $FILE_PATH"
# return 0
else
echo "根据提供的路径无法找到任何视频项目,路径无效。"
# return 1
fi
echo "--------------------------------"
}
# 处理章节
function handle_part() {
local part_dir="$1"
local part_name=$(basename "$part_dir")
local project_name=$(basename "$(dirname "$part_dir")")
local output_file="$2/merge-script_$part_name.sh"
local is_echo_enabled="$3"
# 根据第3个参数是否存在决定是否打印详情
[[ -n "$is_echo_enabled" ]] && echo "处理章节:$project_name/$part_name"
# 检测JSON文件路径的有效性
local FILE_JSON="$part_dir/$JSON_FILENAME"
if [[ ! -f "$FILE_JSON" ]]; then
FILE_JSON=""
fi
# 查找VIDEO文件
local FILE_VIDEO=""
if find_file_at_depth "$VIDEO_FILENAME" "$part_dir" "1"; then
FILE_VIDEO="$FILE_PATH"
fi
# 查找AUDIO文件
local FILE_AUDIO=""
if find_file_at_depth "$AUDIO_FILENAME" "$part_dir" "1"; then
FILE_AUDIO="$FILE_PATH"
fi
# 如已存在则跳过
[[ -f "$output_file" && ! -e "${output_file}.merging" ]] && echo "跳过 '$(basename "$output_file")':已存在。" && return 2
# 创建进行中标记
echo "" > "${output_file}.merging"
# 模拟导出
cat<<EOF >> "$output_file"
#!/bin/bash
# 定义源文件路径
PART_DIR="$part_dir"
FILE_JSON="$FILE_JSON"
FILE_VIDEO="$FILE_VIDEO"
FILE_AUDIO="$FILE_AUDIO"
# 定义是否包含多集
IS_MULTI_PART="$3"
# 定义输出文件夹
OUTPUT_DIR="$OUTPUT_DIR"
EOF
cat<<'EOF' >> "$output_file"
# 定义输出文件名缺省值
DEFAULT_VIDEO_NAME=$(date +"%Y-%m-%d_%H-%M-%S-%3N")
# 定义临时文件路径
TEMP_DIR="$OUTPUT_DIR/.temp"
TEMP_VIDEO="$TEMP_DIR/video"
TEMP_AUDIO="$TEMP_DIR/audio"
TEMP_JSON="$TEMP_DIR/json"
# 创建临时文件夹
[[ ! -e "$TEMP_DIR" ]] && mkdir -p "$TEMP_DIR"
echo ""
# 通过rish将源文件复制到临时文件夹
until rish -c "cp '$FILE_JSON' '$TEMP_JSON'; cp '$FILE_VIDEO' '$TEMP_VIDEO'; cp '$FILE_AUDIO' '$TEMP_AUDIO';"; do
echo "貌似无法连接到Shizuku,重试中..."
echo "请确保Shizuku服务及APP均处于运行状态。"
sleep 1
done
# 验证JSON键值的有效性
function is_key_valid() {
local key_name="$1"
local json_file="$2"
KEY_VALUE=$(jq -r "${key_name}" "$json_file")
# echo "键值:$KEY_VALUE"
if [[ "$KEY_VALUE" != "null" && -n "$KEY_VALUE" ]]; then
return 0
else
return 1
fi
}
# 检查JSON文件是否合法
if [[ -f "$TEMP_JSON" ]] && (jq -e . "$TEMP_JSON" >/dev/null 2>&1); then
# echo "我说 OK-OK. Good 佛祖 for everyone!"
VIDEO_NAME=""
is_key_valid ".owner_name" "$TEMP_JSON" && VIDEO_NAME+="@$KEY_VALUE" # 作者
is_key_valid ".title" "$TEMP_JSON" && VIDEO_NAME+="_$KEY_VALUE" # 标题
is_key_valid ".quality_pithy_description" "$TEMP_JSON" && VIDEO_NAME+="_$KEY_VALUE" # 清晰度
is_key_valid ".page_data.page" "$TEMP_JSON" && PART_NAME="P$KEY_VALUE" # && VIDEO_NAME+="_$KEY_VALUE" # 章节号
is_key_valid ".page_data.part" "$TEMP_JSON" && PART_NAME+="-$KEY_VALUE" # && VIDEO_NAME+="-$KEY_VALUE" # 章节标题
# 若键值均无效则使用当前时间戳作为文件名
if [ ! -n "$VIDEO_NAME" ]; then
VIDEO_NAME=$DEFAULT_VIDEO_NAME
fi
else
# echo "警告:找不到视频信息!"
# echo "佛祖说不行,只能 七十二万!"
# 若JSON文件非法则使用当前时间戳作为文件名
VIDEO_NAME=$DEFAULT_VIDEO_NAME
fi
# 多集视频的情况需要对输出路径做出相应调整
if [[ -n "$IS_MULTI_PART" ]]; then
# OUTPUT_DIR="$OUTPUT_DIR/$VIDEO_NAME"
# [[ ! -e "$OUTPUT_DIR" ]] && mkdir "$OUTPUT_DIR"
# VIDEO_NAME="$PART_NAME"
[[ ! -e "$OUTPUT_DIR/$VIDEO_NAME" ]] && mkdir "$OUTPUT_DIR/$VIDEO_NAME"
VIDEO_NAME="$VIDEO_NAME/$PART_NAME"
fi
# 如果已存在则跳过,否则创建“合并中”标记
[[ -f "$OUTPUT_DIR/$VIDEO_NAME.mp4" && ! -e "$OUTPUT_DIR/$VIDEO_NAME.mp4.merging" ]] && { echo "跳过 '$OUTPUT_DIR/$VIDEO_NAME.mp4':已存在"; } && rm -rf "$TEMP_DIR" && rm $0 && exit
echo "" > "$OUTPUT_DIR/$VIDEO_NAME.mp4.merging"
# 合并视频并删除临时文件
echo "合并视频文件..."
ffmpeg -hide_banner -y -i "$TEMP_VIDEO" -i "$TEMP_AUDIO" -c copy "$OUTPUT_DIR/$VIDEO_NAME.mp4" >/dev/null 2>&1 && { echo "成功!已经保存到:'$VIDEO_NAME.mp4'"; } || { echo "合并过程中出现问题。"; }
echo ""
echo "清理临时数据..."
rm -rf "$TEMP_DIR" && rm "$OUTPUT_DIR/$VIDEO_NAME.mp4.merging" && rm "$0" && { [[ -n "$IS_MULTI_PART" && -z "$(ls -A "$(dirname "$0")")" ]] && rm -r "$(dirname "$0")"; } && exit
EOF
# 删除进行中标记
rm -f "${output_file}.merging"
}
# 处理项目
function handle_project() {
local project_dir="$1"
local progress="$2"
local project_name=$(basename "$project_dir")
local output_dir="$OUTPUT_DIR/$project_name"
# 如已存在则跳过
[[ -e "$output_dir" && ! -e "${output_dir}.merging" ]] && echo "跳过 '$(basename $output_dir)':已存在。" && return 2
# 创建进行中标记
echo "" > "${output_dir}.merging"
# 计算章节数量
local part_count=$(find "$project_dir" -mindepth "$((2+1))" -maxdepth "$((2+1))" -type f -name "$VIDEO_FILENAME" -printf '.' | wc -c)
echo "****$progress 项目名称:$project_name 项目章节数量:$part_count"
if [[ "$part_count" -gt 1 ]]; then
# echo 处理项目各章节。
# 将各章节内容保存在以项目名称命名的文件夹中
mkdir -p "$output_dir"
for part_dir in "$project_dir"/*; do
if [[ -d "$part_dir" ]]; then
handle_part "$part_dir" "$output_dir" "1"
fi
done
else
# echo 项目只包含单个内容,按单内容处理。
local part_dir=$(find "$project_dir" -mindepth "$((0+1))" -maxdepth "$((0+1))" -type d -name "*")
handle_part "$part_dir" "$OUTPUT_DIR"
fi
# 删除进行中标记
rm -f "${output_dir}.merging"
}
# 处理仓库(所有项目)
function handle_project_batch() {
local projects_dir="$1"
local project_count=$(find "$projects_dir" -mindepth "$((0+1))" -maxdepth "$((0+1))" -type d -name "*" -printf '.' | wc -c)
local part_count=$(find "$projects_dir" -mindepth "$((1+1))" -maxdepth "$((1+1))" -type d -name "*" -printf '.' | wc -c)
# 导出全部项目前手动进行确认
echo "共 $project_count 个项目,含 $part_count 条内容,确定要全部导出? [y/N]"
REPLY="N"
read REPLY
if [[ "$REPLY" == "Y" || "$REPLY" == "y" ]]; then
echo "导出 $project_count 个项目..."
# 初始化进度数据
local num=0
local progress=""
for project_dir in "$projects_dir"/*; do
if [[ -d "$project_dir" ]]; then
# 将进度信息传递给项目处理函数,将其打印出来,便于掌握进度情况
num=$((num+1))
progress=" $((num*100/project_count))% ($num/$project_count)"
handle_project "$project_dir" "$progress"
fi
done
else
echo "你取消了操作。"
fi
}
# ================================操作逻辑部分================================
echo ""
echo "BASE_DIR=$BASE_DIR"
echo ""
# 如已经直接从输入获取到路径深度,则无需再进行深度确认
if [[ -n "$PATH_DEPTH" ]]; then
echo "路径深度已提前确认,深度为 $PATH_DEPTH。"
else
echo "路径深度未提前确认,获取路径深度。"
get_path_depth "$VIDEO_FILENAME" "$BASE_DIR" "$MAX_DEPTH"
fi
echo ""
# 根据路径深度,执行不同行为
# “仓库”级别深度的处理
if [[ "$PATH_DEPTH" == "3" ]]; then
PART_DIR=""
PROJECT_DIR=""
PROJECTS_DIR="$BASE_DIR"
handle_project_batch "$PROJECTS_DIR"
# “项目”级别深度的处理
elif [[ "$PATH_DEPTH" == "2" ]]; then
PART_DIR=""
PROJECT_DIR="$BASE_DIR"
PROJECTS_DIR=$(dirname "$PROJECT_DIR")
handle_project "$PROJECT_DIR"
# “章节”级别深度的处理
elif [[ "$PATH_DEPTH" == "1" ]]; then
PART_DIR="$BASE_DIR"
PROJECT_DIR=$(dirname "$PART_DIR")
PROJECTS_DIR=$(dirname "$PROJECT_DIR")
handle_part "$PART_DIR" "$OUTPUT_DIR" "1"
# “章节内容”级别深度的处理
elif [[ "$PATH_DEPTH" == "0" ]]; then
PART_DIR=$(dirname "$BASE_DIR")
PROJECT_DIR=$(dirname $PART_DIR)
PROJECTS_DIR=$(dirname "$PROJECT_DIR")
handle_part "$PART_DIR" "$OUTPUT_DIR" "1"
fi
echo "操作结束。"
echo "【第一阶段结束】"
exit
休息睡觉
好了,经历了连续二三十个小时的爆肝(好吧,我承认是我太菜了),我终于也可以搞玩收工了。重点不在于结果,而在于探索的过程。哈!休息睡觉
美味的床,啊不,美丽的床,还是不对,啊总之,大床正在向我招手,呼哈嘿!
话说,我做个这玩意干啥?
欢迎来到这里!
我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。
注册 关于