|
#!/bin/bash |
|
# |
|
# Smart Image Manager for LLM Integration |
|
# Version: 1.0.0 |
|
# Author: GitHub Copilot |
|
# Description: 智能图片上传管理工具,支持去重、链接续期、多平台存储 |
|
# |
|
|
|
set -euo pipefail |
|
|
|
# ============================================================================ |
|
# 配置区域 |
|
# ============================================================================ |
|
|
|
# 默认配置 |
|
DEFAULT_CONFIG_DIR="$HOME/.image-manager" |
|
DEFAULT_CONFIG_FILE="$DEFAULT_CONFIG_DIR/config" |
|
DEFAULT_INDEX_FILE="$DEFAULT_CONFIG_DIR/index.json" |
|
DEFAULT_STORAGE_PROVIDER="azure" |
|
DEFAULT_CONTAINER="temp-images" |
|
DEFAULT_LINK_EXPIRY="3600" # 1小时 |
|
DEFAULT_MAX_FILE_SIZE="10485760" # 10MB |
|
DEFAULT_ALLOWED_FORMATS="jpg,jpeg,png,gif,webp" |
|
DEFAULT_AUTO_CLEANUP_DAYS="7" |
|
|
|
# 运行时变量 |
|
SCRIPT_NAME=$(basename "$0") |
|
SCRIPT_VERSION="1.0.0" |
|
VERBOSE=false |
|
DRY_RUN=false |
|
FORCE_UPLOAD=false |
|
CONFIG_FILE="$DEFAULT_CONFIG_FILE" |
|
INDEX_FILE="$DEFAULT_INDEX_FILE" |
|
AUTO_CLEANUP_DAYS="$DEFAULT_AUTO_CLEANUP_DAYS" |
|
|
|
# ============================================================================ |
|
# 工具函数 |
|
# ============================================================================ |
|
|
|
# 日志函数 |
|
log() { |
|
local level="$1" |
|
shift |
|
local message="$*" |
|
local timestamp=$(date '+%Y-%m-%d %H:%M:%S') |
|
|
|
case "$level" in |
|
"INFO") if [ "$VERBOSE" = true ]; then echo "[$timestamp] ℹ️ $message"; fi ;; |
|
"WARN") if [ "$VERBOSE" = true ]; then echo "[$timestamp] ⚠️ $message" >&2; fi ;; |
|
"ERROR") echo "[$timestamp] ❌ $message" >&2 ;; |
|
"DEBUG") if [ "$VERBOSE" = true ]; then echo "[$timestamp] 🔍 $message"; fi ;; |
|
"SUCCESS") if [ "$VERBOSE" = true ]; then echo "[$timestamp] ✅ $message"; fi ;; |
|
esac |
|
} |
|
|
|
# 错误处理 |
|
error_exit() { |
|
local error_code="${2:-1}" |
|
log "ERROR" "$1" |
|
|
|
# 清理临时文件 |
|
cleanup_temp_files |
|
|
|
exit "$error_code" |
|
} |
|
|
|
# 清理临时文件 |
|
cleanup_temp_files() { |
|
if [ -f "${INDEX_FILE}.tmp" ]; then |
|
rm -f "${INDEX_FILE}.tmp" |
|
log "DEBUG" "已清理临时索引文件" |
|
fi |
|
} |
|
|
|
# 信号处理函数 |
|
handle_interrupt() { |
|
log "WARN" "收到中断信号,正在清理..." |
|
cleanup_temp_files |
|
exit 130 # SIGINT 的标准退出码 |
|
} |
|
|
|
# 设置信号处理 |
|
trap handle_interrupt SIGINT SIGTERM |
|
|
|
# 依赖检查 |
|
check_dependencies() { |
|
local deps=("rclone" "jq") |
|
local missing=() |
|
|
|
for dep in "${deps[@]}"; do |
|
if ! command -v "$dep" &> /dev/null; then |
|
missing+=("$dep") |
|
fi |
|
done |
|
|
|
if [ ${#missing[@]} -gt 0 ]; then |
|
error_exit "缺少依赖: ${missing[*]}. 请安装后重试." |
|
fi |
|
|
|
log "DEBUG" "Dependencies check passed" |
|
} |
|
|
|
# 检查下载依赖 |
|
check_download_dependencies() { |
|
if ! command -v curl &> /dev/null && ! command -v wget &> /dev/null; then |
|
error_exit "下载功能需要 curl 或 wget" |
|
fi |
|
} |
|
|
|
# 配置文件管理 |
|
migrate_old_config() { |
|
local old_config="$HOME/.image_manager.conf" |
|
local old_index="$HOME/.image_index.json" |
|
local migrated=false |
|
|
|
# 迁移配置文件 |
|
if [ -f "$old_config" ] && [ ! -f "$CONFIG_FILE" ]; then |
|
log "INFO" "检测到旧配置文件,正在迁移..." |
|
|
|
# 确保新目录存在 |
|
mkdir -p "$(dirname "$CONFIG_FILE")" |
|
|
|
# 复制配置文件 |
|
cp "$old_config" "$CONFIG_FILE" |
|
log "SUCCESS" "配置文件已迁移: $old_config -> $CONFIG_FILE" |
|
migrated=true |
|
fi |
|
|
|
# 迁移索引文件 |
|
if [ -f "$old_index" ] && [ ! -f "$INDEX_FILE" ]; then |
|
log "INFO" "检测到旧索引文件,正在迁移..." |
|
|
|
# 确保新目录存在 |
|
mkdir -p "$(dirname "$INDEX_FILE")" |
|
|
|
# 复制索引文件 |
|
cp "$old_index" "$INDEX_FILE" |
|
log "SUCCESS" "索引文件已迁移: $old_index -> $INDEX_FILE" |
|
migrated=true |
|
fi |
|
|
|
if [ "$migrated" = true ]; then |
|
log "INFO" "迁移完成!旧文件已保留,您可以手动删除:" |
|
[ -f "$old_config" ] && log "INFO" " rm $old_config" |
|
[ -f "$old_index" ] && log "INFO" " rm $old_index" |
|
fi |
|
} |
|
|
|
create_default_config() { |
|
# 确保配置目录存在 |
|
if [ ! -d "$(dirname "$CONFIG_FILE")" ]; then |
|
mkdir -p "$(dirname "$CONFIG_FILE")" |
|
log "INFO" "已创建配置目录: $(dirname "$CONFIG_FILE")" |
|
fi |
|
|
|
cat > "$CONFIG_FILE" << EOF |
|
# Image Manager Configuration |
|
STORAGE_PROVIDER="$DEFAULT_STORAGE_PROVIDER" |
|
CONTAINER="$DEFAULT_CONTAINER" |
|
LINK_EXPIRY="$DEFAULT_LINK_EXPIRY" |
|
MAX_FILE_SIZE="$DEFAULT_MAX_FILE_SIZE" |
|
ALLOWED_FORMATS="$DEFAULT_ALLOWED_FORMATS" |
|
|
|
# rclone 配置名称 (请修改为您的配置) |
|
RCLONE_REMOTE="your-remote-name" |
|
|
|
# 高级选项 |
|
ENABLE_COMPRESSION=true |
|
COMPRESSION_QUALITY=85 |
|
AUTO_CLEANUP_DAYS=7 |
|
EOF |
|
|
|
log "INFO" "已创建默认配置文件: $CONFIG_FILE" |
|
} |
|
|
|
load_config() { |
|
# 尝试迁移旧配置 |
|
migrate_old_config |
|
|
|
if [ ! -f "$CONFIG_FILE" ]; then |
|
log "WARN" "配置文件不存在,创建默认配置" |
|
create_default_config |
|
fi |
|
|
|
# shellcheck source=/dev/null |
|
source "$CONFIG_FILE" |
|
log "DEBUG" "配置文件加载完成" |
|
} |
|
|
|
# 索引文件管理 |
|
init_index_file() { |
|
# 确保索引文件目录存在 |
|
if [ ! -d "$(dirname "$INDEX_FILE")" ]; then |
|
mkdir -p "$(dirname "$INDEX_FILE")" |
|
log "INFO" "已创建索引目录: $(dirname "$INDEX_FILE")" |
|
fi |
|
|
|
if [ ! -f "$INDEX_FILE" ]; then |
|
echo '{}' > "$INDEX_FILE" |
|
log "INFO" "已初始化索引文件: $INDEX_FILE" |
|
fi |
|
} |
|
|
|
# 文件哈希计算 |
|
calculate_hash() { |
|
local file="$1" |
|
|
|
if command -v md5 &> /dev/null; then |
|
md5 -q "$file" |
|
elif command -v md5sum &> /dev/null; then |
|
md5sum "$file" | cut -d' ' -f1 |
|
else |
|
error_exit "无法找到 md5 或 md5sum 命令" |
|
fi |
|
} |
|
|
|
# 文件验证 |
|
validate_file() { |
|
local file="$1" |
|
|
|
# 检查文件是否存在 |
|
if [ ! -f "$file" ]; then |
|
error_exit "文件不存在: $file" |
|
fi |
|
|
|
# 检查文件大小 |
|
local file_size |
|
if [[ "$OSTYPE" == "darwin"* ]]; then |
|
file_size=$(stat -f%z "$file") |
|
else |
|
file_size=$(stat -c%s "$file") |
|
fi |
|
|
|
if [ "$file_size" -gt "$MAX_FILE_SIZE" ]; then |
|
error_exit "文件过大: ${file_size} bytes (最大: ${MAX_FILE_SIZE} bytes)" |
|
fi |
|
|
|
# 检查文件格式 |
|
local extension="${file##*.}" |
|
extension=$(echo "$extension" | tr '[:upper:]' '[:lower:]') |
|
|
|
if [[ ",$ALLOWED_FORMATS," != *",$extension,"* ]]; then |
|
error_exit "不支持的文件格式: $extension (支持: $ALLOWED_FORMATS)" |
|
fi |
|
|
|
log "DEBUG" "文件验证通过: $file" |
|
} |
|
|
|
# 索引操作 |
|
get_record() { |
|
local hash="$1" |
|
jq -r ".\"$hash\" // empty" "$INDEX_FILE" |
|
} |
|
|
|
save_record() { |
|
local hash="$1" |
|
local remote_path="$2" |
|
local url="$3" |
|
local local_path="$4" |
|
local timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ") |
|
|
|
# 验证必要参数 |
|
if [ -z "$hash" ] || [ -z "$remote_path" ] || [ -z "$url" ]; then |
|
error_exit "保存记录时缺少必要参数" |
|
fi |
|
|
|
# DRY_RUN 模式下不保存记录 |
|
if [ "$DRY_RUN" = true ]; then |
|
log "DEBUG" "[DRY RUN] 模拟保存记录: $hash" |
|
return 0 |
|
fi |
|
|
|
# 备份原索引文件 |
|
if [ -f "$INDEX_FILE" ]; then |
|
if ! cp "$INDEX_FILE" "${INDEX_FILE}.backup"; then |
|
error_exit "无法创建索引文件备份" |
|
fi |
|
fi |
|
|
|
local record |
|
if ! record=$(jq -n \ |
|
--arg remote "$remote_path" \ |
|
--arg url "$url" \ |
|
--arg local "$local_path" \ |
|
--arg time "$timestamp" \ |
|
'{remote_path: $remote, url: $url, local_path: $local, uploaded_at: $time}' 2>/dev/null); then |
|
error_exit "创建记录对象失败" |
|
fi |
|
|
|
# 原子性写入 |
|
if ! jq ".\"$hash\" = $record" "$INDEX_FILE" > "${INDEX_FILE}.tmp" 2>/dev/null; then |
|
# 恢复备份 |
|
if [ -f "${INDEX_FILE}.backup" ]; then |
|
mv "${INDEX_FILE}.backup" "$INDEX_FILE" |
|
fi |
|
error_exit "更新索引文件失败" |
|
fi |
|
|
|
if ! mv "${INDEX_FILE}.tmp" "$INDEX_FILE"; then |
|
# 恢复备份 |
|
if [ -f "${INDEX_FILE}.backup" ]; then |
|
mv "${INDEX_FILE}.backup" "$INDEX_FILE" |
|
fi |
|
error_exit "保存索引文件失败" |
|
fi |
|
|
|
# 清理备份文件 |
|
rm -f "${INDEX_FILE}.backup" |
|
|
|
log "DEBUG" "记录已保存: $hash" |
|
} |
|
|
|
# 存储操作 |
|
upload_to_storage() { |
|
local file="$1" |
|
local remote_name="$2" |
|
local remote_path="${RCLONE_REMOTE}:${CONTAINER}/${remote_name}" |
|
|
|
# 验证输入参数 |
|
if [ -z "$file" ] || [ -z "$remote_name" ]; then |
|
error_exit "上传函数缺少必要参数" |
|
fi |
|
|
|
# 验证文件存在性 |
|
if [ ! -f "$file" ]; then |
|
error_exit "要上传的文件不存在: $file" |
|
fi |
|
|
|
if [ "$DRY_RUN" = true ]; then |
|
echo "https://example.com/dry-run-url" |
|
return 0 |
|
fi |
|
|
|
# 检查rclone配置 |
|
if ! rclone config show "$RCLONE_REMOTE" >/dev/null 2>&1; then |
|
error_exit "rclone 远程配置 '$RCLONE_REMOTE' 不存在或无效" |
|
fi |
|
|
|
# 使用 copyto 来指定具体的目标文件名 |
|
# 将所有rclone输出重定向到stderr,避免污染函数返回值 |
|
local upload_result=0 |
|
if [ "$VERBOSE" = true ]; then |
|
rclone copyto "$file" "$remote_path" --progress >&2 |
|
upload_result=$? |
|
else |
|
rclone copyto "$file" "$remote_path" --progress >/dev/null 2>&1 |
|
upload_result=$? |
|
fi |
|
|
|
# 检查上传是否成功 |
|
if [ $upload_result -ne 0 ]; then |
|
case $upload_result in |
|
1) error_exit "rclone 语法或使用错误" ;; |
|
2) error_exit "rclone 找不到文件或目录" ;; |
|
3) error_exit "rclone 目标目录不存在或无权限" ;; |
|
4) error_exit "rclone 网络连接失败" ;; |
|
*) error_exit "rclone 上传失败 (退出码: $upload_result)" ;; |
|
esac |
|
fi |
|
|
|
# 生成临时链接 |
|
local url |
|
local link_attempts=3 |
|
local attempt=1 |
|
|
|
while [ $attempt -le $link_attempts ]; do |
|
if url=$(rclone link "$remote_path" 2>/dev/null); then |
|
break |
|
fi |
|
|
|
log "DEBUG" "链接生成失败,重试 $attempt/$link_attempts" |
|
sleep 1 |
|
((attempt++)) |
|
done |
|
|
|
if [ $attempt -gt $link_attempts ]; then |
|
log "WARN" "无法生成临时链接,返回直接路径" >&2 |
|
url="$remote_path" |
|
fi |
|
|
|
echo "$url" |
|
} |
|
|
|
check_remote_exists() { |
|
local remote_name="$1" |
|
|
|
# DRY_RUN 模式下模拟检查结果 |
|
if [ "$DRY_RUN" = true ]; then |
|
# 模拟:假设文件存在(这样可以测试续期链接逻辑) |
|
log "DEBUG" "[DRY RUN] 模拟检查远程文件存在: $remote_name" |
|
return 0 |
|
fi |
|
|
|
rclone lsf "${RCLONE_REMOTE}:${CONTAINER}/" | grep -q "^${remote_name}$" |
|
} |
|
|
|
renew_link() { |
|
local remote_name="$1" |
|
local remote_path="${RCLONE_REMOTE}:${CONTAINER}/${remote_name}" |
|
|
|
if [ "$DRY_RUN" = true ]; then |
|
echo "https://example.com/dry-run-renewed-url" |
|
return 0 |
|
fi |
|
|
|
local url |
|
if ! url=$(rclone link "$remote_path" 2>/dev/null); then |
|
log "WARN" "无法生成新链接,返回直接路径" >&2 |
|
url="$remote_path" |
|
fi |
|
|
|
echo "$url" |
|
} |
|
|
|
# ============================================================================ |
|
# 主要功能 |
|
# ============================================================================ |
|
|
|
# 下载图片 |
|
download_image() { |
|
local hash_or_url="$1" |
|
local output_dir="${2:-.}" # 默认下载到当前目录 |
|
|
|
log "INFO" "查找图片: $hash_or_url" |
|
|
|
local hash="" |
|
local record="" |
|
local url="" |
|
local filename="" |
|
|
|
# 判断输入是哈希还是URL |
|
if [[ "$hash_or_url" =~ ^https?:// ]]; then |
|
# 输入是URL,查找对应的记录 |
|
hash=$(jq -r "to_entries[] | select(.value.url == \"$hash_or_url\") | .key" "$INDEX_FILE" | head -1) |
|
if [ -z "$hash" ]; then |
|
error_exit "未找到对应的记录: $hash_or_url" |
|
fi |
|
else |
|
# 输入可能是哈希(支持部分匹配) |
|
if [ ${#hash_or_url} -ge 8 ]; then |
|
hash=$(jq -r "keys[]" "$INDEX_FILE" | grep "^$hash_or_url" | head -1) |
|
else |
|
error_exit "哈希长度至少需要8位字符" |
|
fi |
|
|
|
if [ -z "$hash" ]; then |
|
error_exit "未找到匹配的记录: $hash_or_url" |
|
fi |
|
fi |
|
|
|
# 获取记录信息 |
|
record=$(get_record "$hash") |
|
url=$(echo "$record" | jq -r '.url') |
|
local remote_path=$(echo "$record" | jq -r '.remote_path') |
|
local original_local_path=$(echo "$record" | jq -r '.local_path // empty') |
|
|
|
if [ -z "$url" ] || [ "$url" = "null" ]; then |
|
error_exit "记录中没有有效的URL" |
|
fi |
|
|
|
# 确定输出文件名 |
|
if [ -n "$original_local_path" ] && [ "$original_local_path" != "null" ]; then |
|
filename=$(basename "$original_local_path") |
|
else |
|
filename="$remote_path" |
|
fi |
|
|
|
# 规范化输出路径,避免双斜杠 |
|
if [[ "$output_dir" == */ ]]; then |
|
local output_path="${output_dir}${filename}" |
|
else |
|
local output_path="$output_dir/$filename" |
|
fi |
|
|
|
if [ "$DRY_RUN" = true ]; then |
|
log "INFO" "[DRY RUN] 将下载: $url -> $output_path" |
|
echo "$output_path" |
|
return 0 |
|
fi |
|
|
|
# 检查输出目录是否存在 |
|
if [ ! -d "$output_dir" ]; then |
|
log "INFO" "创建输出目录: $output_dir" |
|
mkdir -p "$output_dir" |
|
fi |
|
|
|
# 检查文件是否已存在 |
|
if [ -f "$output_path" ] && [ "$FORCE_UPLOAD" = false ]; then |
|
if [ "$VERBOSE" = true ]; then |
|
log "WARN" "文件已存在: $output_path (使用 --force 覆盖)" |
|
fi |
|
echo "$output_path" |
|
return 0 |
|
fi |
|
|
|
log "INFO" "下载图片: $filename" |
|
log "DEBUG" "从 $url 下载到 $output_path" |
|
|
|
# 尝试使用curl下载 |
|
local download_result=0 |
|
if command -v curl &> /dev/null; then |
|
if [ "$VERBOSE" = true ]; then |
|
curl -L -o "$output_path" "$url" --progress-bar --max-time 300 --retry 3 |
|
download_result=$? |
|
else |
|
curl -L -o "$output_path" "$url" -s --max-time 300 --retry 3 |
|
download_result=$? |
|
fi |
|
# 备用wget |
|
elif command -v wget &> /dev/null; then |
|
if [ "$VERBOSE" = true ]; then |
|
wget -O "$output_path" "$url" --progress=bar --timeout=300 --tries=3 |
|
download_result=$? |
|
else |
|
wget -O "$output_path" "$url" -q --timeout=300 --tries=3 |
|
download_result=$? |
|
fi |
|
else |
|
error_exit "需要 curl 或 wget 来下载文件" |
|
fi |
|
|
|
# 检查下载是否成功 |
|
if [ $download_result -ne 0 ]; then |
|
# 清理可能的损坏文件 |
|
[ -f "$output_path" ] && rm -f "$output_path" |
|
|
|
case $download_result in |
|
6|7) error_exit "下载失败: 无法连接到服务器" ;; |
|
22) error_exit "下载失败: HTTP 错误 (可能是链接已过期)" ;; |
|
28) error_exit "下载失败: 连接超时" ;; |
|
*) error_exit "下载失败 (退出码: $download_result)" ;; |
|
esac |
|
fi |
|
|
|
# 验证下载的文件 |
|
if [ ! -f "$output_path" ]; then |
|
error_exit "下载完成但文件不存在: $output_path" |
|
fi |
|
|
|
# 检查文件大小 |
|
local file_size=0 |
|
if [[ "$OSTYPE" == "darwin"* ]]; then |
|
file_size=$(stat -f%z "$output_path" 2>/dev/null || echo "0") |
|
else |
|
file_size=$(stat -c%s "$output_path" 2>/dev/null || echo "0") |
|
fi |
|
|
|
if [ "$file_size" -eq 0 ]; then |
|
rm -f "$output_path" |
|
error_exit "下载的文件为空" |
|
fi |
|
|
|
# 检查下载是否成功并验证文件完整性 |
|
if [ "$VERBOSE" = true ]; then |
|
log "SUCCESS" "下载完成: $output_path" |
|
|
|
# 验证下载的文件 |
|
local downloaded_hash |
|
if ! downloaded_hash=$(calculate_hash "$output_path"); then |
|
rm -f "$output_path" |
|
error_exit "无法计算下载文件的哈希值" |
|
fi |
|
|
|
if [ "$downloaded_hash" = "$hash" ]; then |
|
log "SUCCESS" "文件完整性验证通过" |
|
else |
|
rm -f "$output_path" |
|
error_exit "文件哈希不匹配,可能文件已损坏或被修改" |
|
fi |
|
else |
|
# 非verbose模式,静默验证文件完整性 |
|
local downloaded_hash |
|
if ! downloaded_hash=$(calculate_hash "$output_path"); then |
|
rm -f "$output_path" |
|
error_exit "无法计算下载文件的哈希值" |
|
fi |
|
|
|
if [ "$downloaded_hash" != "$hash" ]; then |
|
rm -f "$output_path" |
|
error_exit "文件哈希不匹配,下载失败" |
|
fi |
|
fi |
|
|
|
echo "$output_path" |
|
} |
|
|
|
process_image() { |
|
local file="$1" |
|
|
|
# 验证输入参数 |
|
if [ -z "$file" ]; then |
|
error_exit "缺少文件路径参数" |
|
fi |
|
|
|
log "INFO" "处理图片: $file" |
|
|
|
# 验证文件 |
|
if ! validate_file "$file"; then |
|
error_exit "文件验证失败: $file" |
|
fi |
|
|
|
# 计算哈希 |
|
local hash |
|
if ! hash=$(calculate_hash "$file"); then |
|
error_exit "计算文件哈希失败: $file" |
|
fi |
|
log "DEBUG" "文件哈希: $hash" |
|
|
|
# 生成远程文件名 |
|
local extension="${file##*.}" |
|
if [ -z "$extension" ]; then |
|
error_exit "无法确定文件扩展名: $file" |
|
fi |
|
local remote_name="img_${hash}.${extension}" |
|
|
|
# 检查本地记录 |
|
local existing_record |
|
if ! existing_record=$(get_record "$hash"); then |
|
log "DEBUG" "获取记录失败,将作为新文件处理" |
|
existing_record="" |
|
fi |
|
|
|
if [ -n "$existing_record" ] && [ "$FORCE_UPLOAD" = false ]; then |
|
log "INFO" "🔄 发现历史图片,检查远程状态..." |
|
|
|
local remote_path |
|
if ! remote_path=$(echo "$existing_record" | jq -r '.remote_path' 2>/dev/null); then |
|
log "WARN" "解析远程路径失败,重新上传" |
|
elif check_remote_exists "$remote_path"; then |
|
log "SUCCESS" "远程文件存在,续期链接" |
|
log "INFO" "续期链接: ${RCLONE_REMOTE}:${CONTAINER}/${remote_path}" |
|
|
|
local new_url |
|
if ! new_url=$(renew_link "$remote_path"); then |
|
log "WARN" "续期链接失败,重新上传" |
|
else |
|
# 更新记录 |
|
if save_record "$hash" "$remote_path" "$new_url" "$file"; then |
|
echo "$new_url" |
|
return 0 |
|
else |
|
log "WARN" "保存记录失败,但返回新URL" |
|
echo "$new_url" |
|
return 0 |
|
fi |
|
fi |
|
else |
|
log "WARN" "远程文件丢失,重新上传" |
|
fi |
|
fi |
|
|
|
# 上传新文件 |
|
log "INFO" "📤 上传新图片..." |
|
log "INFO" "上传文件: $file -> ${RCLONE_REMOTE}:${CONTAINER}/${remote_name}" |
|
|
|
local new_url |
|
if ! new_url=$(upload_to_storage "$file" "$remote_name"); then |
|
error_exit "上传文件失败: $file" |
|
fi |
|
|
|
# 保存记录 |
|
if ! save_record "$hash" "$remote_name" "$new_url" "$file"; then |
|
log "WARN" "保存记录失败,但上传成功" |
|
fi |
|
|
|
log "SUCCESS" "上传完成" |
|
echo "$new_url" |
|
} |
|
|
|
# 清理过期文件 |
|
cleanup_expired() { |
|
local days="${1:-$AUTO_CLEANUP_DAYS}" |
|
|
|
# 验证天数参数 |
|
if ! [[ "$days" =~ ^[0-9]+$ ]]; then |
|
error_exit "无效的天数参数: $days" |
|
fi |
|
|
|
log "INFO" "清理 $days 天前的文件..." |
|
|
|
if [ "$DRY_RUN" = true ]; then |
|
# 验证索引文件 |
|
if [ ! -f "$INDEX_FILE" ]; then |
|
log "INFO" "[DRY RUN] 索引文件不存在,无文件需要清理" |
|
echo "0" |
|
return 0 |
|
fi |
|
|
|
# 计算过期时间戳 |
|
local cutoff_date |
|
if [[ "$OSTYPE" == "darwin"* ]]; then |
|
if ! cutoff_date=$(date -v-"${days}d" -u +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null); then |
|
error_exit "无法计算过期时间戳" |
|
fi |
|
else |
|
if ! cutoff_date=$(date -d "$days days ago" -u +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null); then |
|
error_exit "无法计算过期时间戳" |
|
fi |
|
fi |
|
|
|
# 查找过期记录 |
|
local expired_hashes |
|
if ! expired_hashes=$(jq -r "to_entries[] | select(.value.uploaded_at < \"$cutoff_date\") | .key" "$INDEX_FILE" 2>/dev/null); then |
|
error_exit "查找过期记录失败" |
|
fi |
|
|
|
local count=0 |
|
log "INFO" "[DRY RUN] 将清理以下过期文件:" |
|
while IFS= read -r hash; do |
|
[ -z "$hash" ] && continue |
|
|
|
local record |
|
if record=$(get_record "$hash" 2>/dev/null); then |
|
local remote_path |
|
if remote_path=$(echo "$record" | jq -r '.remote_path' 2>/dev/null); then |
|
log "INFO" " - $remote_path (${hash:0:8}...)" |
|
((count++)) |
|
fi |
|
fi |
|
done <<< "$expired_hashes" |
|
|
|
if [ $count -eq 0 ]; then |
|
log "INFO" "[DRY RUN] 没有过期文件需要清理" |
|
else |
|
log "INFO" "[DRY RUN] 共找到 $count 个过期文件" |
|
fi |
|
|
|
echo "$count" |
|
return 0 |
|
fi |
|
|
|
# 验证索引文件 |
|
if [ ! -f "$INDEX_FILE" ]; then |
|
log "WARN" "索引文件不存在,无需清理" |
|
echo "0" |
|
return 0 |
|
fi |
|
|
|
# 获取过期时间戳 |
|
local cutoff_date |
|
if [[ "$OSTYPE" == "darwin"* ]]; then |
|
if ! cutoff_date=$(date -v-"${days}d" -u +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null); then |
|
error_exit "无法计算过期时间戳" |
|
fi |
|
else |
|
if ! cutoff_date=$(date -d "$days days ago" -u +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null); then |
|
error_exit "无法计算过期时间戳" |
|
fi |
|
fi |
|
|
|
# 查找过期记录 |
|
local expired_hashes |
|
if ! expired_hashes=$(jq -r "to_entries[] | select(.value.uploaded_at < \"$cutoff_date\") | .key" "$INDEX_FILE" 2>/dev/null); then |
|
error_exit "查找过期记录失败" |
|
fi |
|
|
|
local count=0 |
|
local failed_count=0 |
|
|
|
while IFS= read -r hash; do |
|
[ -z "$hash" ] && continue |
|
|
|
local record |
|
if ! record=$(get_record "$hash"); then |
|
log "WARN" "无法读取记录: $hash" |
|
((failed_count++)) |
|
continue |
|
fi |
|
|
|
local remote_path |
|
if ! remote_path=$(echo "$record" | jq -r '.remote_path' 2>/dev/null); then |
|
log "WARN" "无法解析远程路径: $hash" |
|
((failed_count++)) |
|
continue |
|
fi |
|
|
|
log "INFO" "删除过期文件: $remote_path" |
|
|
|
# 删除远程文件(允许失败,可能已经被删除) |
|
if ! rclone delete "${RCLONE_REMOTE}:${CONTAINER}/$remote_path" 2>/dev/null; then |
|
log "WARN" "删除远程文件失败或文件不存在: $remote_path" |
|
fi |
|
|
|
# 从索引中删除 |
|
if ! jq "del(.\"$hash\")" "$INDEX_FILE" > "${INDEX_FILE}.tmp" 2>/dev/null; then |
|
log "WARN" "更新索引失败: $hash" |
|
((failed_count++)) |
|
continue |
|
fi |
|
|
|
if ! mv "${INDEX_FILE}.tmp" "$INDEX_FILE"; then |
|
log "WARN" "保存索引失败: $hash" |
|
((failed_count++)) |
|
continue |
|
fi |
|
|
|
((count++)) |
|
done <<< "$expired_hashes" |
|
|
|
if [ "$VERBOSE" = true ]; then |
|
if [ $failed_count -gt 0 ]; then |
|
log "WARN" "已清理 $count 个过期文件,$failed_count 个失败" |
|
else |
|
log "SUCCESS" "已清理 $count 个过期文件" |
|
fi |
|
fi |
|
|
|
echo "$count" |
|
} |
|
|
|
# 列出所有记录 |
|
list_images() { |
|
# 检查索引文件是否存在 |
|
if [ ! -f "$INDEX_FILE" ]; then |
|
if [ "$VERBOSE" = true ]; then |
|
log "WARN" "索引文件不存在: $INDEX_FILE" |
|
fi |
|
echo "{}" |
|
return 0 |
|
fi |
|
|
|
# 验证索引文件可读性 |
|
if [ ! -r "$INDEX_FILE" ]; then |
|
if [ "$VERBOSE" = true ]; then |
|
log "ERROR" "索引文件不可读: $INDEX_FILE" |
|
fi |
|
echo "{}" |
|
return 1 |
|
fi |
|
|
|
# 验证JSON格式 |
|
if ! jq empty "$INDEX_FILE" 2>/dev/null; then |
|
if [ "$VERBOSE" = true ]; then |
|
log "ERROR" "索引文件JSON格式无效: $INDEX_FILE" |
|
fi |
|
echo "{}" |
|
return 1 |
|
fi |
|
|
|
# 检查索引文件是否为空或无效 |
|
local record_count |
|
if ! record_count=$(jq -r 'keys | length' "$INDEX_FILE" 2>/dev/null); then |
|
if [ "$VERBOSE" = true ]; then |
|
log "ERROR" "无法读取索引文件内容" |
|
fi |
|
echo "{}" |
|
return 1 |
|
fi |
|
|
|
if [ "$record_count" -eq 0 ]; then |
|
if [ "$VERBOSE" = true ]; then |
|
log "INFO" "暂无图片记录" |
|
fi |
|
echo "{}" |
|
return 0 |
|
fi |
|
|
|
if [ "$VERBOSE" = true ]; then |
|
log "INFO" "图片记录列表 (共 $record_count 个):" |
|
|
|
# 使用更安全的方式处理输出 |
|
local temp_output |
|
if ! temp_output=$(jq -r 'to_entries[] | "\(.key[0:8])... \(.value.local_path // "N/A") -> \(.value.remote_path) (\(.value.uploaded_at))"' "$INDEX_FILE" 2>/dev/null); then |
|
log "ERROR" "格式化记录列表失败" |
|
echo "{}" |
|
return 1 |
|
fi |
|
|
|
if [ -n "$temp_output" ]; then |
|
echo "$temp_output" | while IFS= read -r line; do |
|
if [ -n "$line" ]; then |
|
echo " $line" |
|
fi |
|
done |
|
fi |
|
else |
|
# 非verbose模式,直接输出JSON(验证后) |
|
if ! cat "$INDEX_FILE" 2>/dev/null; then |
|
echo "{}" |
|
return 1 |
|
fi |
|
fi |
|
} |
|
|
|
# ============================================================================ |
|
# 帮助信息 |
|
# ============================================================================ |
|
|
|
show_help() { |
|
cat << EOF |
|
$SCRIPT_NAME v$SCRIPT_VERSION - 智能图片管理工具 |
|
|
|
用法: |
|
$SCRIPT_NAME [选项] <图片文件> |
|
$SCRIPT_NAME [选项] cleanup [天数] |
|
$SCRIPT_NAME [选项] list |
|
$SCRIPT_NAME [选项] download <哈希|URL> [输出目录] |
|
$SCRIPT_NAME migrate # 迁移旧版本配置文件 |
|
|
|
选项: |
|
-h, --help 显示此帮助信息 |
|
-v, --verbose 详细输出 |
|
-n, --dry-run 试运行模式 |
|
-f, --force 强制重新上传/覆盖下载 |
|
-c, --config FILE 指定配置文件 (默认: $DEFAULT_CONFIG_FILE) |
|
-i, --index FILE 指定索引文件 (默认: $DEFAULT_INDEX_FILE) |
|
cleanup [DAYS] 清理过期文件 (默认: $AUTO_CLEANUP_DAYS 天) |
|
list 列出所有记录 |
|
download HASH|URL [DIR] 下载图片到指定目录 (默认: 当前目录) |
|
init-config 创建默认配置文件 |
|
migrate 迁移旧版本配置到新目录结构 |
|
|
|
示例: |
|
$SCRIPT_NAME image.jpg # 上传图片 |
|
$SCRIPT_NAME --verbose image.png # 详细模式上传 |
|
$SCRIPT_NAME --force image.jpg # 强制重新上传 |
|
$SCRIPT_NAME --cleanup 3 # 清理3天前的文件 |
|
$SCRIPT_NAME --list # 列出所有记录 |
|
$SCRIPT_NAME --download 0858f70b # 用哈希下载(支持部分匹配) |
|
$SCRIPT_NAME --download "https://..." ./downloads/ # 用URL下载到指定目录 |
|
$SCRIPT_NAME --dry-run image.jpg # 试运行 |
|
|
|
配置: |
|
配置文件位置: $DEFAULT_CONFIG_FILE |
|
索引文件位置: $DEFAULT_INDEX_FILE |
|
|
|
支持的存储提供商: Azure Blob, AWS S3, Google Cloud Storage |
|
通过 rclone 配置存储后端 |
|
|
|
依赖: |
|
- rclone: 多云存储工具 |
|
- jq: JSON 处理工具 |
|
- curl 或 wget: 下载工具 |
|
|
|
EOF |
|
} |
|
|
|
# ============================================================================ |
|
# 主程序 |
|
# ============================================================================ |
|
|
|
main() { |
|
local action="" |
|
local target_file="" |
|
local cleanup_days="" |
|
local download_target="" |
|
local download_dir="" |
|
|
|
# 解析命令行参数 |
|
while [[ $# -gt 0 ]]; do |
|
case $1 in |
|
-h|--help) |
|
show_help |
|
exit 0 |
|
;; |
|
-v|--verbose) |
|
VERBOSE=true |
|
shift |
|
;; |
|
-n|--dry-run) |
|
DRY_RUN=true |
|
shift |
|
;; |
|
-f|--force) |
|
FORCE_UPLOAD=true |
|
shift |
|
;; |
|
-c|--config) |
|
CONFIG_FILE="$2" |
|
shift 2 |
|
;; |
|
-i|--index) |
|
INDEX_FILE="$2" |
|
shift 2 |
|
;; |
|
--cleanup) |
|
action="cleanup" |
|
if [[ $# -gt 1 && $2 =~ ^[0-9]+$ ]]; then |
|
cleanup_days="$2" |
|
shift 2 |
|
else |
|
shift |
|
fi |
|
;; |
|
list) |
|
action="list" |
|
shift |
|
;; |
|
cleanup) |
|
action="cleanup" |
|
if [[ $# -gt 1 && $2 =~ ^[0-9]+$ ]]; then |
|
cleanup_days="$2" |
|
shift 2 |
|
else |
|
shift |
|
fi |
|
;; |
|
download) |
|
action="download" |
|
if [[ $# -gt 1 ]]; then |
|
download_target="$2" |
|
shift 2 |
|
# 检查是否有可选的输出目录参数 |
|
if [[ $# -gt 0 && ! $1 =~ ^- ]]; then |
|
download_dir="$1" |
|
shift |
|
fi |
|
else |
|
error_exit "download 需要指定哈希或URL参数" |
|
fi |
|
;; |
|
init-config) |
|
create_default_config |
|
exit 0 |
|
;; |
|
migrate) |
|
migrate_old_config |
|
exit 0 |
|
;; |
|
-*) |
|
error_exit "未知选项: $1" |
|
;; |
|
*) |
|
if [ -z "$target_file" ]; then |
|
target_file="$1" |
|
action="upload" |
|
else |
|
error_exit "只能指定一个图片文件" |
|
fi |
|
shift |
|
;; |
|
esac |
|
done |
|
|
|
# 检查依赖 |
|
check_dependencies |
|
|
|
# 加载配置 |
|
load_config |
|
|
|
# 初始化索引文件 |
|
init_index_file |
|
|
|
# 执行操作 |
|
case "$action" in |
|
"upload") |
|
if [ -z "$target_file" ]; then |
|
error_exit "请指定要上传的图片文件" |
|
fi |
|
process_image "$target_file" |
|
;; |
|
"cleanup") |
|
cleanup_expired "${cleanup_days:-$AUTO_CLEANUP_DAYS}" |
|
;; |
|
"list") |
|
list_images |
|
;; |
|
"download") |
|
if [ -z "$download_target" ]; then |
|
error_exit "请指定要下载的图片哈希或URL" |
|
fi |
|
check_download_dependencies |
|
download_image "$download_target" "${download_dir:-.}" |
|
;; |
|
"") |
|
error_exit "请指定操作。使用 --help 查看帮助信息" |
|
;; |
|
*) |
|
error_exit "未知操作: $action" |
|
;; |
|
esac |
|
} |
|
|
|
# 脚本入口 |
|
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then |
|
main "$@" |
|
fi |