commit 64d2b6376710ae769a554c34471e7709bea2f55b
Author: jkooe <62494645+jkooe@users.noreply.github.com>
Date: Mon Oct 12 17:58:30 2026 +0800
初始化 sdimport:检测视频 SD 卡自动导入工具(macOS/Windows 跨平台)
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..f236a13
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,16 @@
+# 运行时产物(日志/状态含真实卷名、楼栋号、用户名、时间戳,绝不入库)
+runtime/
+
+# 历史备份目录
+src.bak-*/
+
+# WorkBuddy 项目记忆(私人工作记录)
+.workbuddy/
+
+# Python
+__pycache__/
+*.py[cod]
+*.egg-info/
+
+# macOS
+.DS_Store
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..ad05794
--- /dev/null
+++ b/README.md
@@ -0,0 +1,154 @@
+# sdimport — 检测视频 SD 卡自动导入
+
+插入记录仪 SD 卡 → 自动按拍摄日期归类 → 落盘 → 清空卡内副本 → 延时自动弹出。
+
+macOS 与 Windows 共用同一套核心逻辑,系统差异全部收敛在 `src/sdimport/platforms/`。
+
+```
+插卡 → 认卡 → 按日期分组 → 对账 → 倒计时确认 → 拷贝 → 校验 → 清卡 → 完成提示 → 延时弹卡
+```
+
+| 环节 | 规则 |
+|---|---|
+| **认卡** | 不认卷名,认卡内有没有 `VID-YYYYMMDD-HHMMSS.AVI`。换卡、换记录仪、卡被改名都不受影响 |
+| **分组** | 日期只取**文件名**里的八位数,不看卡上目录名(实测过目录名与拍摄日期错位) |
+| **范围** | 只扫 `scan_roots` 指定的目录,**绝不进 `VIDEO/` 的子目录** —— 那里面是历史归档 |
+| **对账** | 已落盘的跳过;同名但体积不符的另起名字,**绝不覆盖** |
+| **校验** | 体积 + 头尾各 64KB 指纹一致才算成功 |
+| **清卡** | 校验通过才删卡内源文件;单文件失败即整批中止,**不删任何源文件** |
+| **弹卡** | 完成后弹提示,N 分钟后自动弹出;期间可点「暂不弹出」,或执行 `sdimport keep` 取消 |
+
+---
+
+## 安装
+
+### macOS(已实测)
+
+```bash
+cd ~/WorkBuddy/检测视频自动导入
+./bin/sdimport selftest # 环境自检
+./bin/sdimport install # 装后台服务(launchd)
+```
+
+若 `install` 提示「未能在本次会话中加载」,双击项目根目录的 **安装后台服务.command**,
+或在终端粘贴 `launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.sdimport.agent.plist`。
+什么都不做也行 —— 注销或重启后 macOS 会自动加载。
+
+触发方式:`WatchPaths` 监视 `/Volumes`(插卡即响应)+ 每 120 秒兜底检查一次。
+
+### Windows(**尚未在真机实测**)
+
+1. 装 Python 3.9+(),安装时勾选 **Add python.exe to PATH**。
+2. 把本项目整个目录拷到那台 Windows 机器上,路径**不要含空格**最省事。
+3. 先自检,再演练,最后安装:
+
+```bat
+bin\sdimport.cmd selftest
+bin\sdimport.cmd scan
+bin\sdimport.cmd once --no-delete
+bin\sdimport.cmd install
+```
+
+或直接双击 **install-service.cmd**。
+
+触发方式:常驻进程 `sdimport daemon` 订阅 WMI 卷插入事件(秒级响应)+ 每 30 秒轮询兜底,
+由任务计划 `sdimport` 在登录时自动拉起。
+
+> **可选提速**:`pip install wmi` 之后插卡是秒级响应;不装也能用,只是最坏延迟 30 秒。
+
+---
+
+## 命令
+
+| 命令 | 作用 |
+|---|---|
+| `sdimport scan` | 演练:只显示会导入什么,**不做任何写入** |
+| `sdimport once` | 立即执行一次导入 |
+| `sdimport once --no-delete` | 导入但保留卡内文件 |
+| `sdimport status` | 服务状态、待弹出的任务、最近导入记录 |
+| `sdimport log` | 查看最近日志 |
+| `sdimport keep` | 取消待执行的自动弹出(卡继续挂着) |
+| `sdimport eject` | 立刻弹出 SD 卡 |
+| `sdimport selftest` | 环境自检 |
+| `sdimport install` / `uninstall` | 安装 / 卸载后台服务 |
+| `sdimport daemon` | 常驻进程(Windows 用,一般无需手动跑) |
+
+macOS 用 `./bin/sdimport`,Windows 用 `bin\sdimport.cmd`,参数完全一致。
+
+---
+
+## 配置(`config.json`)
+
+改动前请先读 `.workbuddy/memory/MEMORY.md` 的「铁律」一节。
+
+| 键 | 默认 | 说明 |
+|---|---|---|
+| `target_root` | `~/Downloads/检测视频` | 落盘根目录,按 `//` 组织 |
+| `filename_regex` | `^VID-(\d{8})-(\d{6})\.(AVI\|MP4\|…)$` | **只碰匹配此模式的文件**,其余一律不动 |
+| `scan_roots` | `["", "VIDEO", "DCIM:2", "MOVIE", "PRIVATE"]` | 扫描根。`""` 是卷根;`:N` 是该根下最多再进几层子目录 |
+| `scan_max_depth` | `0` | 未写 `:N` 时的默认下潜层数 |
+| `min_video_bytes` | `1024` | 只挡 0 字节 / 畸形残片 |
+| `tiny_video_bytes` | `1048576` | 小于此值的只**标注**、照样导入,不丢数据 |
+| `delete_source_after_verify` | `true` | 校验通过后是否清卡 |
+| `notify_countdown_seconds` | `60` | 开始导入前的倒计时,期间可取消 |
+| `eject_after_import` | `true` | 导入完成后是否自动弹出 |
+| `eject_delay_seconds` | `300` | 延时多久弹出 |
+| `completion_dialog_seconds` | `30` | 完成提示框停留秒数;设 `0` 则只发通知不弹框 |
+| `eject_finder_fallback` | `false` | macOS:改走「访达推出」通道。**需先授予「自动化」权限**,否则会弹框阻塞 |
+| `windows_only_removable` | `true` | Windows:只把可移动磁盘当候选卷(硬安全线,别轻易关) |
+
+调试用环境变量:`SDIMPORT_TARGET_ROOT`、`SDIMPORT_COUNTDOWN`、`SDIMPORT_EJECT_DELAY`、
+`SDIMPORT_COMPLETION_DIALOG`。
+
+---
+
+## 目录结构
+
+```
+install-service.cmd Windows:双击安装后台服务
+uninstall-service.cmd Windows:双击卸载
+bin/sdimport macOS / Linux 入口
+bin/sdimport.cmd Windows 入口
+bin/on-volume-change.sh launchd 触发入口
+src/sdimport/
+ planner.py 按日期分组与落盘规划 (跨平台)
+ transfer.py 拷贝 → 校验 → 删源 (跨平台)
+ pipeline.py 主流程编排 (跨平台)
+ scanner.py 卷内录像扫描规则 (跨平台)
+ runner.py auto 一轮流程 (跨平台)
+ daemon.py 常驻进程(Windows 触发用) (跨平台)
+ journal.py 日志、状态、单实例锁 (跨平台)
+ paths.py 路径与配置 (跨平台)
+ volumes.py / notify.py / ejector.py 薄门面,委托平台
+ platforms/
+ base.py 接口与 Volume 模型
+ macos.py /Volumes + diskutil + osascript + launchd
+ windows.py 盘符 + kernel32 + PowerShell + 任务计划
+ generic.py 兜底(Linux 等)
+runtime/logs/ import-YYYYMMDD.log、launchd.err.log、eject.log
+runtime/state/ state.json、pending-eject.json
+```
+
+---
+
+## 故障排查
+
+| 现象 | 处置 |
+|---|---|
+| 插卡没反应 | `sdimport status` 看服务在不在;`sdimport log` 看最近日志 |
+| 说「判定为非记录仪卡」 | 卡上确实没有符合 `filename_regex` 的文件;或卡上文件都在子目录里(按设计不纳入) |
+| 说「另有 N 个录像不纳入」 | 那些在扫描根的子目录里,属归档范围,**按设计不动**。真要处理就改 `scan_roots` |
+| macOS 卡弹不出来 | 多半是访达窗口开着这张卡。关掉窗口,或在访达侧边栏点「推出」 |
+| Windows 卡弹不出来 | 关掉资源管理器里该盘的窗口;或右键该盘选「弹出」 |
+| 想临时保住卡不弹 | `sdimport keep` |
+
+**排查第一站永远是 `runtime/logs/` 下的日志**,每次导入的每个文件都留了痕。
+
+---
+
+## 边界声明
+
+- 本程序只做「SD 卡 → `//` → 清卡」这一段。
+ 电梯编号识别由 `elevator-video-classifier` skill 负责,那一步需要人工校对,两者不重叠。
+- **Windows 侧代码未在真机实测**(开发机是 macOS)。写法上已尽量保守
+ (纯标准库优先、依赖缺失自动降级、无 GUI 会话也能工作),但仍建议先跑 `selftest` 与 `scan`。
diff --git a/bin/on-volume-change.sh b/bin/on-volume-change.sh
new file mode 100755
index 0000000..10bf0e2
--- /dev/null
+++ b/bin/on-volume-change.sh
@@ -0,0 +1,32 @@
+#!/bin/bash
+# 插卡触发入口(由 launchd 调用)。
+#
+# launchd 在 /Volumes 变化或每 120 秒兜底触发本脚本;真正的逻辑与并发控制在 Python 侧。
+# 这里只负责:等待挂载落定 -> 交给 sdimport auto。
+# 路径按脚本自身位置推导,项目整个搬走也不会失效。
+
+set -u
+
+BIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+PROJECT_ROOT="$(dirname "$BIN_DIR")"
+
+if [ -x /opt/homebrew/bin/python3 ]; then
+ PYTHON_BIN=/opt/homebrew/bin/python3
+elif command -v python3 >/dev/null 2>&1; then
+ PYTHON_BIN="$(command -v python3)"
+elif [ -x /usr/bin/python3 ]; then
+ PYTHON_BIN=/usr/bin/python3
+else
+ echo "[sdimport] 找不到可用的 python3" >&2
+ exit 127
+fi
+
+mkdir -p "$PROJECT_ROOT/runtime/logs"
+
+# 等文件系统挂载稳定(launchd 触发时卷可能刚出现)
+sleep 4
+
+export PYTHONPATH="$PROJECT_ROOT/src"
+export PYTHONDONTWRITEBYTECODE=1
+
+exec "$PYTHON_BIN" -m sdimport.cli auto "$@"
diff --git a/bin/sdimport b/bin/sdimport
new file mode 100755
index 0000000..c5377c9
--- /dev/null
+++ b/bin/sdimport
@@ -0,0 +1,31 @@
+#!/bin/bash
+# sdimport 命令行包装(macOS / Linux)。
+#
+# sdimport scan 演练,看看会导入什么
+# sdimport once 立即导入一次
+# sdimport status 查看服务状态与历史
+# sdimport keep 取消待执行的自动弹出
+# sdimport install 安装插卡自动触发
+#
+# 路径全部按脚本自身位置推导,项目整个搬走也不会失效。
+
+set -u
+
+BIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+PROJECT_ROOT="$(dirname "$BIN_DIR")"
+
+export PYTHONPATH="$PROJECT_ROOT/src"
+export PYTHONDONTWRITEBYTECODE=1
+
+if [ -x /opt/homebrew/bin/python3 ]; then
+ PYTHON_BIN=/opt/homebrew/bin/python3
+elif command -v python3 >/dev/null 2>&1; then
+ PYTHON_BIN="$(command -v python3)"
+elif [ -x /usr/bin/python3 ]; then
+ PYTHON_BIN=/usr/bin/python3
+else
+ echo "找不到 python3,请先安装 Python 3.9 及以上。" >&2
+ exit 1
+fi
+
+exec "$PYTHON_BIN" -m sdimport.cli "$@"
diff --git a/bin/sdimport.cmd b/bin/sdimport.cmd
new file mode 100644
index 0000000..f6d09a2
--- /dev/null
+++ b/bin/sdimport.cmd
@@ -0,0 +1,16 @@
+@echo off
+rem sdimport launcher for Windows. Keep this file ASCII-only (encoding safety).
+setlocal
+chcp 65001 >nul
+set "ROOT=%~dp0.."
+set "PYEXE="
+for /f "delims=" %%i in ('where python.exe 2^>nul') do if not defined PYEXE set "PYEXE=%%i"
+if not defined PYEXE for /f "delims=" %%i in ('where py.exe 2^>nul') do if not defined PYEXE set "PYEXE=%%i"
+if not defined PYEXE (
+ echo [ERROR] Python not found. Install Python 3.9+ and tick "Add python.exe to PATH".
+ exit /b 1
+)
+set "PYTHONPATH=%ROOT%\src"
+set "PYTHONDONTWRITEBYTECODE=1"
+"%PYEXE%" -m sdimport.cli %*
+exit /b %ERRORLEVEL%
diff --git a/config.json b/config.json
new file mode 100644
index 0000000..6209735
--- /dev/null
+++ b/config.json
@@ -0,0 +1,47 @@
+{
+ "target_root": "~/Downloads/检测视频",
+ "filename_regex": "^VID-(\\d{8})-(\\d{6})\\.(?:AVI|MP4|MOV|MTS|MPG|MPEG)$",
+ "case_insensitive": true,
+ "scan_roots": [
+ "",
+ "VIDEO",
+ "DCIM:2",
+ "MOVIE",
+ "PRIVATE"
+ ],
+ "scan_max_depth": 0,
+ "deny_dir_names": [
+ "System Volume Information",
+ "$RECYCLE.BIN",
+ "System",
+ "FOUND.000"
+ ],
+ "report_skipped_archives": true,
+ "min_video_bytes": 1024,
+ "tiny_video_bytes": 1048576,
+ "copy_chunk_mb": 8,
+ "verify_sample_kb": 64,
+ "min_free_space_ratio": 1.1,
+ "delete_source_after_verify": true,
+ "delete_batch_size": 10,
+ "notify_countdown_seconds": 60,
+ "eject_after_import": true,
+ "eject_delay_seconds": 300,
+ "completion_dialog_seconds": 30,
+ "eject_retry_attempts": 5,
+ "eject_retry_interval": 15,
+ "eject_finder_fallback": false,
+ "deny_volumes": [
+ "Macintosh HD",
+ "Recovery",
+ "TimeMachine",
+ "VM",
+ "Preboot",
+ "Update"
+ ],
+ "allow_readonly_copy": true,
+ "windows_only_removable": true,
+ "settle_seconds": 4,
+ "stability_checks": 3,
+ "stability_interval": 0.8
+}
diff --git a/install-service.cmd b/install-service.cmd
new file mode 100644
index 0000000..ffa3887
--- /dev/null
+++ b/install-service.cmd
@@ -0,0 +1,10 @@
+@echo off
+chcp 65001 >nul
+cd /d "%~dp0"
+echo ================================================================
+echo sdimport - install background service (Windows)
+echo ================================================================
+echo.
+call "%~dp0bin\sdimport.cmd" install
+echo.
+pause
diff --git a/src/sdimport/__init__.py b/src/sdimport/__init__.py
new file mode 100644
index 0000000..394deea
--- /dev/null
+++ b/src/sdimport/__init__.py
@@ -0,0 +1,3 @@
+"""检测视频 SD 卡自动导入。"""
+
+__version__ = "1.0.0"
diff --git a/src/sdimport/cli.py b/src/sdimport/cli.py
new file mode 100644
index 0000000..c7aba1e
--- /dev/null
+++ b/src/sdimport/cli.py
@@ -0,0 +1,428 @@
+"""sdimport - 检测视频 SD 卡自动导入(macOS / Windows 通用)。
+
+子命令:
+ auto 后台触发入口:macOS 由 launchd 调用,Windows 由常驻进程调用
+ daemon 常驻进程(Windows:订阅卷插入事件 + 轮询兜底)
+ scan 演练:只显示会导入什么,不做任何写入
+ once 立即手动执行一次导入
+ keep 取消待执行的自动弹出
+ eject 立即弹出 SD 卡
+ status 查看服务状态与最近记录
+ log 查看最近日志
+ install 安装后台服务(插卡自动触发)
+ uninstall 卸载后台服务
+ selftest 环境自检
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import subprocess
+import sys
+import time
+from datetime import datetime
+from pathlib import Path
+
+from . import notify, pipeline, transfer
+from .journal import (
+ Journal,
+ SingleInstanceLock,
+ human_duration,
+ human_size,
+ load_state,
+ note_run,
+ note_volume_seen,
+ save_state,
+)
+from .paths import (
+ CONFIG_PATH,
+ LOCK_PATH,
+ LOG_DIR,
+ PROJECT_ROOT,
+ RUNTIME_DIR,
+ STATE_PATH,
+ ensure_dirs,
+ load_config,
+ write_default_config,
+)
+from .platforms import current as _platform
+from .volumes import list_candidate_volumes, probe, volume_names
+
+#: 服务标识(Windows 的任务名 / macOS 的 launchd label)
+SERVICE_ID = "com.sdimport.agent"
+
+SAMPLES = [
+ "VID-20260912-090430.AVI",
+ "VID-20260911-131707.AVI",
+ "vid-20260910-083000.avi",
+ "IMG_0001.AVI",
+ "20260912_090430.AVI",
+]
+
+
+# --------------------------------------------------------------------------
+# 工具
+# --------------------------------------------------------------------------
+
+
+def volume_snapshot(cfg: dict) -> list[str]:
+ """当前候选卷的名字快照(轻量,不问系统要卷详情)。"""
+ return volume_names(cfg)
+
+
+
+# --------------------------------------------------------------------------
+# 子命令
+# --------------------------------------------------------------------------
+
+
+def cmd_auto(args: argparse.Namespace) -> int:
+ """后台触发入口。
+
+ macOS 由 launchd 调用(WatchPaths + StartInterval);
+ Windows 由常驻进程 daemon 调用。真正的流程在 runner.auto_cycle,两端共用。
+ """
+ from .runner import auto_cycle
+
+ return auto_cycle(
+ assume_yes=bool(getattr(args, "yes", False)),
+ lock_stale=float(getattr(args, "lock_stale", 3600.0)),
+ )
+
+
+def _run_interactive(args: argparse.Namespace, dry_run: bool) -> int:
+ cfg = load_config()
+ ensure_dirs()
+ journal = Journal("scan" if dry_run else "once")
+
+ target = Path(args.volume) if args.volume else None
+ if target is not None and not target.exists():
+ journal.error(f"路径不存在:{target}")
+ return 2
+
+ opts = pipeline.RunOptions(
+ dry_run=dry_run,
+ assume_yes=bool(getattr(args, "yes", False)) or dry_run,
+ no_delete=bool(getattr(args, "no_delete", False)),
+ target_volume=target,
+ quiet=bool(getattr(args, "quiet", False)),
+ )
+ result = pipeline.run(cfg, journal, opts)
+ if dry_run:
+ print()
+ print(f"演练结束:将导入 {len(result.cards)} 张卡的待处理文件(未做任何写入)")
+ return 0 if not result.aborted else 1
+
+
+def cmd_status(args: argparse.Namespace) -> int:
+ cfg = load_config()
+ state = load_state()
+ print("=" * 68)
+ print("sdimport 状态")
+ print("=" * 68)
+ print(f"项目目录 : {PROJECT_ROOT}")
+ print(f"配置文件 : {CONFIG_PATH}")
+ print(f"落盘根目录 : {cfg['target_root']}")
+ print(f"倒计时 : {cfg.get('notify_countdown_seconds')} 秒")
+ print(f"导入后清卡 : {'是' if cfg.get('delete_source_after_verify') else '否'}")
+
+ svc = _platform.service_status()
+ if svc.installed:
+ print(f"后台服务 : {svc.detail}")
+ else:
+ print(f"后台服务 : {svc.detail}(执行 sdimport install 安装)")
+
+ from . import ejector
+
+ pending = ejector.read_pending()
+ if pending:
+ print(
+ f"自动弹出 : 「{pending.get('name')}」计划 {pending.get('due_at_text')} 弹出"
+ f"(要取消就执行 sdimport keep)"
+ )
+ else:
+ print("自动弹出 : 无待执行任务")
+
+ print()
+ print("当前卷:")
+ vols = list_candidate_volumes(cfg)
+ if not vols:
+ print(" (无候选卷)")
+ for vol in vols:
+ print(
+ f" - {vol.describe()} 容量 {human_size(vol.total_bytes)}"
+ f" 可用 {human_size(vol.free_bytes)} uuid={vol.uuid or '未知'}"
+ )
+
+ hist = state.get("history", [])
+ print()
+ print(f"最近导入记录(共 {len(hist)} 条):")
+ if not hist:
+ print(" (暂无)")
+ for rec in reversed(hist[-8:]):
+ flag = "中断" if rec.get("aborted") else "完成"
+ reason = f"({rec.get('abort_reason')})" if rec.get("aborted") else ""
+ print(
+ f" {rec.get('at')} {flag}{reason} 导入 {rec.get('imported', 0)} 个 / "
+ f"{human_size(rec.get('bytes', 0))} / 耗时 {human_duration(rec.get('seconds', 0))}"
+ )
+ print()
+ print(f"状态文件 : {STATE_PATH}")
+ return 0
+
+
+def cmd_log(args: argparse.Namespace) -> int:
+ ensure_dirs()
+ files = sorted(LOG_DIR.glob("import-*.log"))
+ if not files:
+ print("暂无日志")
+ return 0
+ path = files[-1]
+ print(f"# {path}")
+ lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
+ for line in lines[-int(args.lines) :]:
+ print(line)
+ return 0
+
+
+def cmd_install(args: argparse.Namespace) -> int:
+ ensure_dirs()
+ write_default_config()
+ status = _platform.install_service(PROJECT_ROOT)
+ print(status.detail)
+ if status.ok and status.installed:
+ print(" 停用 : sdimport uninstall")
+ if _platform.needs_daemon:
+ print(" 说明 : 本平台靠常驻进程触发,登录后会随任务计划自动启动")
+ return 0
+ return 1
+
+
+def cmd_uninstall(args: argparse.Namespace) -> int:
+ status = _platform.uninstall_service(PROJECT_ROOT)
+ print(status.detail)
+ return 0 if status.ok else 1
+
+
+def cmd_daemon(args: argparse.Namespace) -> int:
+ """常驻进程入口(Windows)。
+
+ macOS 用 launchd 的 WatchPaths + StartInterval,不需要常驻;
+ Windows 没有等价物,由常驻进程订阅卷插入事件做秒级响应,
+ 并辅以定时轮询兜底(两者任一可用即可工作)。
+ """
+ from .daemon import run_forever
+
+ return run_forever(interval=float(args.interval))
+
+
+def cmd_selftest(args: argparse.Namespace) -> int:
+ ok = True
+ print("=" * 68)
+ print("sdimport 环境自检")
+ print("=" * 68)
+
+ print(f"[1] Python : {sys.version.split()[0]} ({sys.executable})")
+ if sys.version_info < (3, 9):
+ print(" !! 需要 Python 3.9 及以上")
+ ok = False
+
+ ensure_dirs()
+ for d in (RUNTIME_DIR, LOG_DIR):
+ print(f"[2] 运行目录 : {d} {'可写' if os.access(d, os.W_OK) else '不可写'}")
+ if not os.access(d, os.W_OK):
+ ok = False
+
+ cfg = load_config()
+ root = Path(cfg["target_root"])
+ root.mkdir(parents=True, exist_ok=True)
+ writable = os.access(root, os.W_OK)
+ import shutil
+
+ free = shutil.disk_usage(root if root.exists() else Path.home()).free
+ print(f"[3] 落盘根目录 : {root}")
+ print(f" {'可写' if writable else '不可写'},可用 {human_size(free)}")
+ if not writable:
+ ok = False
+
+ print(f"[4] 平台 : {_platform.name}")
+ diag = getattr(_platform, "diagnose", None)
+ if callable(diag):
+ try:
+ for label, good, note in diag():
+ suffix = f" {note}" if note else ""
+ print(f" - {label:<14} : {'可用' if good else '异常'}{suffix}")
+ if not good:
+ ok = False
+ except Exception as exc: # noqa: BLE001 - 自检本身出错不该带崩
+ print(f" !! 平台诊断自身出错:{exc!r}")
+ ok = False
+
+ notified = notify.notify("sdimport 自检", "通知通道正常")
+ print(f"[5] 通知通道 : {'可用' if notified else '不可用'}")
+ if not notified:
+ ok = False
+
+ print("[6] 文件名正则自检:")
+ from .planner import parse_date
+
+ for name in SAMPLES:
+ d = parse_date(name, cfg)
+ expect = "应识别" if name.lower().startswith("vid") else "应忽略"
+ got = d or "忽略"
+ mark = "OK " if (bool(d) == (expect == "应识别")) else "!! "
+ print(f" {mark}{name:<28} -> {got}")
+ if mark.strip() == "!!":
+ ok = False
+
+ svc = _platform.service_status()
+ tail = "" if svc.installed else "(执行 sdimport install 安装)"
+ print(f"[7] 后台服务 : {svc.detail}{tail}")
+ print()
+ print("自检结果:" + ("全部通过" if ok else "存在问题,见上方 !! 标记"))
+ return 0 if ok else 1
+
+
+def cmd_keep(args: argparse.Namespace) -> int:
+ """取消待执行的「延时自动弹出 SD 卡」。"""
+ from . import ejector
+
+ if ejector.cancel("用户手动执行 sdimport keep"):
+ print("已取消自动弹出 SD 卡,卡保持挂载。")
+ return 0
+ print("当前没有待执行的自动弹出任务。")
+ return 0
+
+
+def cmd_eject(args: argparse.Namespace) -> int:
+ """立即弹出 SD 卡。"""
+ from . import ejector
+
+ cfg = load_config()
+ target = args.volume
+ if not target:
+ vols = list_candidate_volumes(cfg)
+ if not vols:
+ print("未发现可弹出的卷。")
+ return 1
+ target = str(vols[0].mount_point)
+ mount = Path(target)
+ if not mount.exists():
+ print(f"挂载点不存在:{mount}")
+ return 2
+
+ ejector.cancel("改为立即弹出")
+ print(f"正在推出 {mount.name} …")
+ ok, why = ejector.eject_volume(
+ mount,
+ attempts=2,
+ interval=3.0,
+ use_finder=bool(cfg.get("eject_finder_fallback", False)),
+ )
+ if ok:
+ print(f"{mount.name} {why}。可以拔卡了。")
+ return 0
+ print(f"{mount.name} 弹出失败:{why}")
+ print("多半是卷被占用(访达窗口开着、或桌面显示着这张卡)。")
+ print("处置:关掉这张卡的访达窗口,或在访达侧边栏点该卷右侧的「推出」按钮。")
+ return 1
+
+
+def cmd_delayed_eject(args: argparse.Namespace) -> int:
+ """内部命令:延时弹出任务的实际执行者(由导入完成时以脱离进程方式拉起)。"""
+ from . import ejector
+
+ return ejector.run_pending()
+
+
+# --------------------------------------------------------------------------
+# 参数解析
+# --------------------------------------------------------------------------
+
+
+def build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(
+ prog="sdimport",
+ description="检测视频 SD 卡自动导入:插卡 -> 按日期分类 -> 落盘 -> 清卡",
+ formatter_class=argparse.RawDescriptionHelpFormatter,
+ epilog=(
+ "示例:\n"
+ " sdimport scan 演练,看看会导入什么\n"
+ " sdimport once 立即执行一次\n"
+ " sdimport once --no-delete 执行但保留卡内文件\n"
+ " sdimport keep 取消自动弹出,卡继续挂着\n"
+ " sdimport eject 立刻弹出 SD 卡\n"
+ " sdimport status 查看状态与历史\n"
+ " sdimport install 安装插卡自动触发\n"
+ ),
+ )
+ sub = parser.add_subparsers(dest="cmd", required=True)
+
+ p_auto = sub.add_parser("auto", help="由 launchd 触发(插卡自动执行)")
+ p_auto.add_argument("--yes", action="store_true", help="跳过倒计时确认直接执行")
+ p_auto.add_argument("--lock-stale", type=float, default=3600.0, help="锁的超时秒数")
+ p_auto.set_defaults(func=cmd_auto)
+
+ def add_common(p: argparse.ArgumentParser) -> None:
+ p.add_argument("--volume", help="只处理指定卷(默认扫描全部)")
+ p.add_argument("--yes", action="store_true", help="跳过倒计时确认")
+ p.add_argument("--quiet", action="store_true", help="不发送通知")
+
+ p_scan = sub.add_parser("scan", help="演练:只显示会做什么")
+ add_common(p_scan)
+ p_scan.set_defaults(func=lambda a: _run_interactive(a, dry_run=True))
+
+ p_once = sub.add_parser("once", help="立即执行一次导入")
+ add_common(p_once)
+ p_once.add_argument("--no-delete", action="store_true", help="保留卡内源文件")
+ p_once.set_defaults(func=lambda a: _run_interactive(a, dry_run=False))
+
+ p_status = sub.add_parser("status", help="查看状态与历史")
+ p_status.set_defaults(func=cmd_status)
+
+ p_log = sub.add_parser("log", help="查看最近日志")
+ p_log.add_argument("-n", "--lines", type=int, default=40, help="显示行数")
+ p_log.set_defaults(func=cmd_log)
+
+ p_install = sub.add_parser("install", help="安装 launchd 后台服务")
+ p_install.set_defaults(func=cmd_install)
+
+ p_uninstall = sub.add_parser("uninstall", help="卸载 launchd 后台服务")
+ p_uninstall.set_defaults(func=cmd_uninstall)
+
+ p_test = sub.add_parser("selftest", help="环境自检")
+ p_test.set_defaults(func=cmd_selftest)
+
+ p_keep = sub.add_parser("keep", help="取消待执行的自动弹出 SD 卡")
+ p_keep.set_defaults(func=cmd_keep)
+
+ p_eject = sub.add_parser("eject", help="立即弹出 SD 卡")
+ p_eject.add_argument("--volume", help="指定挂载点(默认取唯一的候选卷)")
+ p_eject.set_defaults(func=cmd_eject)
+
+ p_daemon = sub.add_parser(
+ "daemon", help="常驻进程:订阅卷插入事件 + 轮询兜底(Windows 靠它触发)"
+ )
+ p_daemon.add_argument(
+ "--interval", type=float, default=30.0, help="轮询兜底间隔秒数(默认 30)"
+ )
+ p_daemon.set_defaults(func=cmd_daemon)
+
+ return parser
+
+
+def main(argv: list[str] | None = None) -> int:
+ args_list = list(sys.argv[1:] if argv is None else argv)
+ # 内部命令:延时弹出任务的实际执行者。
+ # 由导入完成时以脱离进程方式拉起,刻意不进帮助列表。
+ if args_list[:1] == ["_delayed-eject"]:
+ return cmd_delayed_eject(argparse.Namespace())
+ parser = build_parser()
+ args = parser.parse_args(args_list)
+ return int(args.func(args))
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/src/sdimport/daemon.py b/src/sdimport/daemon.py
new file mode 100644
index 0000000..f03a01b
--- /dev/null
+++ b/src/sdimport/daemon.py
@@ -0,0 +1,109 @@
+"""常驻进程:订阅卷插入事件 + 轮询兜底。
+
+macOS 用 launchd 的 WatchPaths,不需要这层;
+Windows 没有等价机制,这里做两条腿,任一可用即可工作:
+
+ 1. **WMI 事件订阅**(`Win32_VolumeChangeEvent`)—— 秒级响应
+ 2. **定时轮询** —— 兜底。WMI 并非万能:读卡器一直插着只换卡、
+ WMI 服务异常、没装 wmi 包等情况下事件不会来,靠它保证不漏。
+
+两条腿都指向同一个动作 `runner.auto_cycle`,与 macOS 侧行为一致。
+单轮之间用一把非阻塞锁互斥,避免事件与轮询同时触发跑两遍。
+"""
+
+from __future__ import annotations
+
+import threading
+import time
+
+from .journal import Journal
+from .platforms import current as _platform
+
+
+def _trigger(source: str, journal: Journal, busy: threading.Lock) -> None:
+ if not busy.acquire(blocking=False):
+ journal.log(f"[{source}] 上一轮尚未结束,本次跳过")
+ return
+ try:
+ journal.log(f"[{source}] 触发一轮检查")
+ from .runner import auto_cycle
+
+ auto_cycle(journal=Journal("auto"), lock_stale=3600.0)
+ except Exception as exc: # noqa: BLE001 - 常驻进程绝不能因单次异常退出
+ journal.warn(f"[{source}] 本轮出错:{exc!r}")
+ finally:
+ busy.release()
+
+
+def _poll_loop(
+ interval: float, journal: Journal, busy: threading.Lock, stop: threading.Event
+) -> None:
+ journal.log(f"轮询兜底已启动,间隔 {interval:.0f} 秒")
+ while not stop.wait(interval):
+ _trigger("poll", journal, busy)
+
+
+def _wmi_loop(journal: Journal, busy: threading.Lock, stop: threading.Event) -> None:
+ """订阅 WMI 卷变更事件(仅 Windows)。缺 wmi 包就安静退化成轮询。"""
+ try:
+ import wmi # type: ignore[import-untyped]
+ except ImportError:
+ journal.warn("未安装 wmi 包 → 插卡秒级响应不可用,只靠轮询兜底")
+ journal.warn(" 想开:pip install wmi")
+ return
+
+ try:
+ client = wmi.WMI()
+ watcher = client.Win32_VolumeChangeEvent.watch_for()
+ except Exception as exc: # noqa: BLE001
+ journal.warn(f"WMI 事件订阅失败({exc!r})→ 只靠轮询兜底")
+ return
+
+ journal.log("WMI 卷变更事件订阅已就绪")
+ while not stop.is_set():
+ try:
+ event = watcher(timeout_ms=5000)
+ except Exception as exc: # noqa: BLE001 - 含 wmi 的超时异常,属正常节拍
+ name = type(exc).__name__
+ if "timeout" in name.lower():
+ continue
+ journal.warn(f"WMI 监听异常:{exc!r}")
+ time.sleep(5)
+ continue
+ if event is None:
+ continue
+ drive = getattr(event, "DriveName", "?")
+ kind = getattr(event, "EventType", "?")
+ journal.log(f"卷变更事件:DriveName={drive} EventType={kind}")
+ _trigger("wmi", journal, busy)
+
+
+def run_forever(interval: float = 30.0) -> int:
+ journal = Journal("daemon")
+ journal.log(f"常驻进程启动(平台 {_platform.name},轮询间隔 {interval:.0f} 秒)")
+
+ stop = threading.Event()
+ busy = threading.Lock()
+
+ threading.Thread(
+ target=_poll_loop,
+ args=(max(5.0, float(interval)), journal, busy, stop),
+ daemon=True,
+ name="sdimport-poll",
+ ).start()
+
+ if _platform.needs_daemon:
+ threading.Thread(
+ target=_wmi_loop, args=(journal, busy, stop), daemon=True, name="sdimport-wmi"
+ ).start()
+ else:
+ journal.log("当前平台由系统服务负责触发,这里仅保留轮询兜底")
+
+ try:
+ while not stop.wait(3600):
+ pass
+ except KeyboardInterrupt:
+ journal.log("收到中断,常驻进程退出")
+ finally:
+ stop.set()
+ return 0
diff --git a/src/sdimport/ejector.py b/src/sdimport/ejector.py
new file mode 100644
index 0000000..9b5abbf
--- /dev/null
+++ b/src/sdimport/ejector.py
@@ -0,0 +1,323 @@
+"""导入完成后的延时自动弹出 SD 卡。
+
+用户要的是:导完给个明确交代,然后 5 分钟后自动把卡弹出来,省得手动去推。
+
+安全设计(弹出涉及可移动介质,必须保守):
+ * **绝不打断在跑的导入**:到点前先查 import.lock,有导入在进行就往后顺延。
+ * **可能已被手动处理**:卷不在了就直接收工,不做任何多余动作。
+ * **只弹出,不删除**:全程只调系统的弹出/卸载动作,不碰卡上任何文件。
+ * **允许反悔**:完成提示框里可点「暂不弹出」;事后也可用 `sdimport keep` 取消。
+ * **弹不出来要说清楚**:重试若干次,仍失败就通知用户手动推出,绝不静默失败。
+
+实现要点:延时等待交给一个脱离父进程的独立进程,
+这样触发链路可以立刻收工,不必为等 5 分钟而占着单实例锁。
+任务状态落在 runtime/state/pending-eject.json,进程被系统清掉也能由下次触发补做。
+
+具体的弹出动作由平台实现(macOS:diskutil + 访达;Windows:Shell.Application + mountvol)。
+"""
+
+from __future__ import annotations
+
+import json
+import os
+import subprocess
+import sys
+import time
+from datetime import datetime
+from pathlib import Path
+
+from . import notify
+from .journal import Journal, human_duration, human_size
+from .paths import LOCK_PATH, LOG_DIR, PROJECT_ROOT, STATE_DIR, ensure_dirs
+from .platforms import current as _platform
+
+PENDING_PATH = STATE_DIR / "pending-eject.json"
+EJECT_LOG = LOG_DIR / "eject.log"
+
+# 到点后若仍有导入在跑,最多顺延这么久
+MAX_DEFER_SECONDS = 900
+DEFER_STEP = 10
+CHECK_STEP = 5.0
+
+
+# --------------------------------------------------------------------------
+# 任务状态
+# --------------------------------------------------------------------------
+
+
+def _write_pending(payload: dict) -> None:
+ ensure_dirs()
+ tmp = PENDING_PATH.with_suffix(".json.tmp")
+ tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
+ os.replace(tmp, PENDING_PATH)
+
+
+def read_pending() -> dict | None:
+ if not PENDING_PATH.exists():
+ return None
+ try:
+ data = json.loads(PENDING_PATH.read_text(encoding="utf-8"))
+ return data if isinstance(data, dict) else None
+ except (json.JSONDecodeError, OSError):
+ return None
+
+
+def clear_pending() -> None:
+ try:
+ PENDING_PATH.unlink(missing_ok=True)
+ except OSError:
+ pass
+
+
+def cancel(reason: str = "手动取消") -> bool:
+ """取消待执行的弹出任务。返回是否确实取消了一个任务。"""
+ existed = PENDING_PATH.exists()
+ clear_pending()
+ if existed:
+ _append_eject_log(f"[{_now()}] 弹出任务已取消({reason})\n")
+ return existed
+
+
+def _now() -> str:
+ return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+
+
+def _append_eject_log(line: str) -> None:
+ try:
+ ensure_dirs()
+ with EJECT_LOG.open("a", encoding="utf-8") as fh:
+ fh.write(line)
+ except OSError:
+ pass
+
+
+# --------------------------------------------------------------------------
+# 完成提示
+# --------------------------------------------------------------------------
+
+
+def delay_text(seconds: int) -> str:
+ """把延时说成人话(含短延时的调试值,避免出现「0 分钟后」)。"""
+ seconds = max(0, int(seconds))
+ if seconds >= 120:
+ return f"{seconds // 60} 分钟后"
+ if seconds >= 60:
+ return "1 分钟后"
+ return f"{seconds} 秒后"
+
+
+def completion_message(result, delay_seconds: int) -> str:
+ dates = "、".join(f"{d}({n})" for d, n in sorted(result.dates.items()))
+ lines = [
+ f"{result.imported} 个视频 / {human_size(result.bytes_moved)} / "
+ f"耗时 {human_duration(result.seconds)}",
+ ]
+ if dates:
+ lines.append(f"归入日期:{dates}")
+ if result.deleted:
+ lines.append(f"卡内 {result.deleted} 个文件已清空。")
+ elif result.delete_failed:
+ lines.append(f"有 {result.delete_failed} 个卡内文件未能删除,仍有残留。")
+ else:
+ lines.append("卡内源文件已保留。")
+ lines.append("")
+ lines.append(f"{delay_text(delay_seconds)}自动弹出 SD 卡。")
+ lines.append("如需继续使用这张卡,请点「暂不弹出」。")
+ return "\n".join(lines)
+
+
+def offer_and_schedule(
+ cfg: dict,
+ journal: Journal,
+ result,
+ mount_points: list[Path],
+) -> None:
+ """弹完成提示;按用户选择安排延时弹出。"""
+ if not cfg.get("eject_after_import", True):
+ journal.log("已配置为不自动弹出 SD 卡(eject_after_import=false)")
+ return
+ if not mount_points:
+ return
+
+ delay = max(0, int(cfg.get("eject_delay_seconds", 300)))
+ dialog_seconds = int(cfg.get("completion_dialog_seconds", 30))
+
+ verdict = "ok"
+ if dialog_seconds > 0:
+ verdict = notify.ask_completion(
+ "检测视频:导出完成",
+ completion_message(result, delay) if delay else "导出完成。",
+ timeout=dialog_seconds,
+ keep_label="暂不弹出",
+ ok_label="知道了",
+ )
+ if verdict == "keep":
+ cancel("用户在完成提示中选了「暂不弹出」")
+ journal.log("用户选择暂不弹出,本次不安排自动弹出")
+ return
+ if verdict == "error":
+ # 弹框不可用(无图形会话等):退回通知中心,仍按计划弹出
+ notify.notify(
+ "检测视频:导出完成",
+ f"{result.imported} 个视频 / {human_size(result.bytes_moved)},"
+ f"{delay_text(delay)}自动弹出 SD 卡。",
+ )
+
+ for mount in mount_points:
+ ok = schedule(mount, mount.name, cfg, journal)
+ if not ok:
+ break
+
+
+# --------------------------------------------------------------------------
+# 安排与执行
+# --------------------------------------------------------------------------
+
+
+def _detach_kwargs() -> dict:
+ """让子进程脱离父进程。POSIX 与 Windows 的姿势不同。"""
+ if sys.platform.startswith("win"):
+ return {
+ "creationflags": getattr(subprocess, "DETACHED_PROCESS", 0x00000008)
+ | getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0x00000200)
+ }
+ return {"start_new_session": True}
+
+
+def schedule(mount_point: Path, name: str, cfg: dict, journal: Journal) -> bool:
+ # 下限 10 秒:够用户看见提示并反悔;上限不设,配置写多少就是多少
+ delay = max(10, int(cfg.get("eject_delay_seconds", 300)))
+ payload = {
+ "volume": str(mount_point),
+ "name": name,
+ "delay": delay,
+ "due_at": time.time() + delay,
+ "due_at_text": datetime.fromtimestamp(time.time() + delay).strftime("%H:%M:%S"),
+ "attempts": int(cfg.get("eject_retry_attempts", 5)),
+ "interval": float(cfg.get("eject_retry_interval", 15)),
+ "use_finder": bool(cfg.get("eject_finder_fallback", False)),
+ "created": _now(),
+ }
+ try:
+ _write_pending(payload)
+ except OSError as exc:
+ journal.warn(f"无法写入弹出任务:{exc}")
+ return False
+
+ _append_eject_log(
+ f"[{_now()}] 已安排 {name} 于 {payload['due_at_text']} 自动弹出({delay} 秒后)\n"
+ )
+
+ src_dir = Path(__file__).resolve().parents[1]
+ env = dict(os.environ)
+ env["PYTHONPATH"] = str(src_dir) + os.pathsep + env.get("PYTHONPATH", "")
+ env["PYTHONDONTWRITEBYTECODE"] = "1"
+
+ argv = [sys.executable, "-m", "sdimport.cli", "_delayed-eject"]
+ try:
+ ensure_dirs()
+ with EJECT_LOG.open("ab") as fh:
+ subprocess.Popen(
+ argv,
+ env=env,
+ cwd=str(PROJECT_ROOT),
+ stdin=subprocess.DEVNULL,
+ stdout=fh,
+ stderr=subprocess.STDOUT,
+ **_detach_kwargs(),
+ )
+ except OSError as exc:
+ journal.warn(f"无法启动延时弹出进程:{exc}(可执行 sdimport eject 手动弹出)")
+ return False
+
+ journal.log(f"已安排 {name} 于 {payload['due_at_text']} 自动弹出")
+ return True
+
+
+def eject_volume(
+ mount_point: Path,
+ attempts: int,
+ interval: float,
+ use_finder: bool = False,
+) -> tuple[bool, str]:
+ """弹出卷(委托平台实现)。
+
+ macOS:先 diskutil,必要时改走访达双通道;
+ Windows:Shell.Application 的 Eject 动词,降级到 mountvol /P。
+ 只弹出,不删除任何文件。
+ """
+ return _platform.eject(
+ Path(mount_point),
+ attempts=attempts,
+ interval=interval,
+ use_fallback=use_finder,
+ )
+
+
+def run_pending() -> int:
+ """独立进程入口:等到点,然后弹出。"""
+ journal = Journal("eject")
+ payload = read_pending()
+ if not payload:
+ journal.log("无待执行的弹出任务")
+ return 0
+
+ mount = Path(str(payload.get("volume", "")))
+ name = str(payload.get("name") or mount.name)
+ due_at = float(payload.get("due_at", 0))
+
+ while True:
+ remain = due_at - time.time()
+ if remain <= 0:
+ break
+ time.sleep(min(CHECK_STEP, remain))
+ if read_pending() is None:
+ journal.log("弹出任务已被取消")
+ return 0
+
+ if read_pending() is None:
+ journal.log("弹出任务已被取消")
+ return 0
+
+ # 有导入在跑就顺延,绝不打断拷贝
+ deferred = 0.0
+ while LOCK_PATH.exists() and deferred < MAX_DEFER_SECONDS:
+ journal.log("检测到导入仍在进行,顺延后再弹")
+ time.sleep(DEFER_STEP)
+ deferred += DEFER_STEP
+ if read_pending() is None:
+ journal.log("弹出任务已被取消")
+ return 0
+ if LOCK_PATH.exists():
+ journal.warn("导入长时间未结束,本次放弃自动弹出")
+ clear_pending()
+ notify.notify("检测视频:未自动弹出", f"{name} 因导入未结束而保留挂载,可稍后手动推出。")
+ return 1
+
+ if not mount.exists():
+ journal.log(f"{name} 已不在挂载点,无需弹出")
+ clear_pending()
+ return 0
+
+ attempts = int(payload.get("attempts", 5))
+ interval = float(payload.get("interval", 15))
+ use_finder = bool(payload.get("use_finder", False))
+ ok, why = eject_volume(mount, attempts, interval, use_finder=use_finder)
+ clear_pending()
+
+ if ok:
+ journal.log(f"{name} {why}")
+ _append_eject_log(f"[{_now()}] {name} {why}\n")
+ notify.notify(
+ "检测视频:SD 卡已弹出",
+ f"「{name}」已安全弹出,可以拔卡了。",
+ )
+ return 0
+
+ journal.warn(f"{name} 自动弹出失败:{why}")
+ _append_eject_log(f"[{_now()}] {name} 自动弹出失败:{why}\n")
+ notify.notify(
+ "检测视频:弹出失败",
+ f"「{name}」自动弹出失败({why})。数据已导入完毕,可手动推出该卡。",
+ )
+ return 1
diff --git a/src/sdimport/journal.py b/src/sdimport/journal.py
new file mode 100644
index 0000000..770746c
--- /dev/null
+++ b/src/sdimport/journal.py
@@ -0,0 +1,178 @@
+"""日志与运行状态。
+
+日志:runtime/logs/import-YYYYMMDD.log(追加)
+状态:runtime/state/state.json(卷指纹 / 导入历史)
+"""
+
+from __future__ import annotations
+
+import json
+import os
+import sys
+import time
+from datetime import datetime
+from pathlib import Path
+
+from .paths import LOG_DIR, STATE_DIR, STATE_PATH, ensure_dirs
+
+
+def _ts() -> str:
+ return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+
+
+def human_size(n: int) -> str:
+ units = ["B", "KB", "MB", "GB", "TB"]
+ v = float(n)
+ for u in units:
+ if v < 1024 or u == units[-1]:
+ return f"{v:.1f}{u}" if u != "B" else f"{int(v)}B"
+ v /= 1024
+ return f"{n}B"
+
+
+def human_duration(sec: float) -> str:
+ sec = max(0, int(sec))
+ if sec < 60:
+ return f"{sec}秒"
+ if sec < 3600:
+ return f"{sec // 60}分{sec % 60}秒"
+ return f"{sec // 3600}小时{(sec % 3600) // 60}分"
+
+
+class Journal:
+ """一条运行一份,同时写文件、stderr 与内存副本。"""
+
+ def __init__(self, tag: str = "auto") -> None:
+ ensure_dirs()
+ self.tag = tag
+ self.lines: list[str] = []
+ self.log_path = LOG_DIR / f"import-{datetime.now().strftime('%Y%m%d')}.log"
+ self.t0 = time.time()
+
+ def log(self, msg: str, level: str = "INFO") -> None:
+ line = f"[{_ts()}] [{self.tag}] [{level}] {msg}"
+ self.lines.append(line)
+ try:
+ with self.log_path.open("a", encoding="utf-8") as fh:
+ fh.write(line + "\n")
+ except OSError:
+ pass
+ print(line, file=sys.stderr, flush=True)
+
+ def step(self, msg: str) -> None:
+ self.log(msg, "STEP")
+
+ def warn(self, msg: str) -> None:
+ self.log(msg, "WARN")
+
+ def error(self, msg: str) -> None:
+ self.log(msg, "ERROR")
+
+ @property
+ def elapsed(self) -> float:
+ return time.time() - self.t0
+
+ def tail(self, n: int = 20) -> list[str]:
+ return self.lines[-n:]
+
+
+# --------------------------------------------------------------------------
+# 状态:卷去重 + 导入历史
+# --------------------------------------------------------------------------
+
+
+def load_state() -> dict:
+ ensure_dirs()
+ if not STATE_PATH.exists():
+ return {"volumes": {}, "history": []}
+ try:
+ data = json.loads(STATE_PATH.read_text(encoding="utf-8"))
+ if isinstance(data, dict):
+ data.setdefault("volumes", {})
+ data.setdefault("history", [])
+ return data
+ except (json.JSONDecodeError, OSError):
+ pass
+ return {"volumes": {}, "history": []}
+
+
+def save_state(state: dict) -> None:
+ ensure_dirs()
+ tmp = STATE_PATH.with_suffix(".json.tmp")
+ try:
+ tmp.write_text(json.dumps(state, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
+ os.replace(tmp, STATE_PATH)
+ except OSError:
+ pass
+
+
+def note_volume_seen(state: dict, vol_id: str, name: str) -> None:
+ entry = state["volumes"].setdefault(vol_id, {"name": name, "first_seen": _ts()})
+ entry["name"] = name
+ entry["last_seen"] = _ts()
+
+
+def note_run(state: dict, record: dict) -> None:
+ state["history"].append(record)
+ # 只留最近 200 条
+ if len(state["history"]) > 200:
+ del state["history"][:-200]
+
+
+# --------------------------------------------------------------------------
+# 单实例锁
+# --------------------------------------------------------------------------
+
+
+class SingleInstanceLock:
+ """基于 O_EXCL 的跨进程锁;持有者崩溃超时后自动接管。"""
+
+ def __init__(self, path: Path, stale_after: float = 900.0) -> None:
+ self.path = path
+ self.stale_after = stale_after
+ self.acquired = False
+
+ def acquire(self) -> bool:
+ ensure_dirs()
+ if self.path.exists():
+ try:
+ age = time.time() - self.path.stat().st_mtime
+ except OSError:
+ age = 0.0
+ if age < self.stale_after:
+ return False
+ try:
+ self.path.unlink()
+ except OSError:
+ return False
+ try:
+ fd = os.open(self.path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
+ os.write(fd, f"{os.getpid()} {_ts()}\n".encode())
+ os.close(fd)
+ self.acquired = True
+ return True
+ except FileExistsError:
+ return False
+ except OSError:
+ return False
+
+ def refresh(self) -> None:
+ if self.acquired:
+ try:
+ os.utime(self.path, None)
+ except OSError:
+ pass
+
+ def release(self) -> None:
+ if self.acquired:
+ try:
+ self.path.unlink()
+ except OSError:
+ pass
+ self.acquired = False
+
+ def __enter__(self) -> "SingleInstanceLock":
+ return self
+
+ def __exit__(self, *exc) -> None:
+ self.release()
diff --git a/src/sdimport/notify.py b/src/sdimport/notify.py
new file mode 100644
index 0000000..24e67c5
--- /dev/null
+++ b/src/sdimport/notify.py
@@ -0,0 +1,54 @@
+"""系统通知与确认框(跨平台门面)。
+
+实际实现按运行平台走 platforms.current:
+ * macOS —— osascript 的 `display notification` / `display dialog`
+ * Windows —— PowerShell 的 toast 通知 + tkinter 自绘的带倒计时对话框
+调用方不必关心这些差异,签名与语义两端一致。
+"""
+
+from __future__ import annotations
+
+from .platforms import current as _platform
+
+
+def notify(title: str, message: str, subtitle: str = "", sound: str = "") -> bool:
+ """发一条系统通知。返回是否发出(失败不抛异常,调用方照常继续)。"""
+ return _platform.notify(title, message, subtitle=subtitle, sound=sound)
+
+
+def ask_with_countdown(
+ title: str,
+ message: str,
+ timeout: int = 60,
+ ok_label: str = "立即开始",
+ cancel_label: str = "取消导入",
+) -> str:
+ """开始导入前的确认框。
+
+ 返回 'ok'(点了确认,或超时未操作 —— 用户要的就是「不响应即执行」)/
+ 'cancel'(明确取消)/ 'error'(弹不出来,此时已改为通知 + 静默等待)。
+ """
+ return _platform.ask_countdown(
+ title, message, timeout=timeout, ok_label=ok_label, cancel_label=cancel_label
+ )
+
+
+def ask_completion(
+ title: str,
+ message: str,
+ timeout: int = 30,
+ keep_label: str = "暂不弹出",
+ ok_label: str = "知道了",
+) -> str:
+ """导出完成提示框。返回 'ok'(继续弹出计划)/'keep'(明确暂不弹出)/'error'。"""
+ return _platform.ask_completion(
+ title, message, timeout=timeout, keep_label=keep_label, ok_label=ok_label
+ )
+
+
+def banner(title: str, message: str, timeout: int = 60) -> bool:
+ """纯提示框(无选择语义),超时自动消失。平台没实现就退化成通知。"""
+ impl = getattr(_platform, "banner", None)
+ if callable(impl):
+ return bool(impl(title, message, timeout=timeout))
+ return notify(title, message)
diff --git a/src/sdimport/paths.py b/src/sdimport/paths.py
new file mode 100644
index 0000000..063a9af
--- /dev/null
+++ b/src/sdimport/paths.py
@@ -0,0 +1,131 @@
+"""路径解析与配置加载。
+
+配置优先级:config.json(用户可改) > DEFAULTS(代码内置)
+"""
+
+from __future__ import annotations
+
+import json
+import os
+from pathlib import Path
+
+PROJECT_ROOT = Path(__file__).resolve().parents[2]
+CONFIG_PATH = PROJECT_ROOT / "config.json"
+RUNTIME_DIR = PROJECT_ROOT / "runtime"
+LOG_DIR = RUNTIME_DIR / "logs"
+STATE_DIR = RUNTIME_DIR / "state"
+STATE_PATH = STATE_DIR / "state.json"
+LOCK_PATH = RUNTIME_DIR / "import.lock"
+
+DEFAULTS: dict = {
+ # 视频落盘根目录,按 // 组织
+ "target_root": "~/Downloads/检测视频",
+ # 只认这个文件名模式的文件,其余一律不碰(绝不误删卡上其它素材)
+ "filename_regex": r"^VID-(\d{8})-(\d{6})\.(?:AVI|MP4|MOV|MTS|MPG|MPEG)$",
+ "case_insensitive": True,
+ # ---- 扫描范围(2026-09-15 事故后重定,改动前务必读懂)----
+ # 只在下列「扫描根」下找录像。path 相对卷根("" 即卷根本身),
+ # ":N" 表示在该根下最多再进 N 层子目录,不写则用 scan_max_depth。
+ #
+ # 为什么写死层数、不再靠「本层有没有录像」推断边界 ——
+ # 上一版规则(本层有录像就停止下潜)会自毁:第一次导入把 VIDEO/ 本层清空后,
+ # 第二次扫描发现本层没录像,就下潜进了归档子目录,
+ # 把用户明确说过「不碰」的 409 个 / 33.5GB 历史归档全导走并清空了卡
+ # (2026-09-15 18:45 真实发生)。VIDEO 用 0 层,永不进子目录。
+ "scan_roots": ["", "VIDEO", "DCIM:2", "MOVIE", "PRIVATE"],
+ "scan_max_depth": 0,
+ # 完全不进入的目录名(大小写不敏感),用于躲开卡上无关目录
+ "deny_dir_names": ["System Volume Information", "$RECYCLE.BIN", "System", "FOUND.000"],
+ # 停止下潜时,是否统计并告知被跳过了多少归档录像
+ "report_skipped_archives": True,
+ # 小于此体积的匹配文件视为碎片不导入。只用来挡 0 字节 / 畸形残片,
+ # 不要用来挡「短录制」——2026-09-14 真卡上有个 192KB 的有效短录制差点被静默丢弃。
+ "min_video_bytes": 1024,
+ # 小于此体积的录像会在报告里标注(疑似误触 / 中断的短段),
+ # 但**照样导入**,绝不静默丢数据
+ "tiny_video_bytes": 1048576,
+ # 拷贝块大小 / 校验采样大小
+ "copy_chunk_mb": 8,
+ "verify_sample_kb": 64,
+ # 目标盘剩余空间至少是待导入总量的多少倍
+ "min_free_space_ratio": 1.10,
+ # 校验通过后是否删除卡内源文件
+ "delete_source_after_verify": True,
+ # 删源批量上限(每批之间停顿,避免卡 IO 过载)
+ "delete_batch_size": 10,
+ # 开始导入前的倒计时(秒),期间可取消
+ "notify_countdown_seconds": 60,
+ # ---- 导出完成提示与自动弹出 SD 卡 ----
+ # 导入完成后是否自动弹出 SD 卡(用户要的「5 分钟后弹卡」)
+ "eject_after_import": True,
+ # 导入完成后延迟多少秒弹出,给用户留反悔的余地(完成提示框里可点「暂不弹出」)
+ "eject_delay_seconds": 300,
+ # 完成提示框停留秒数;超时按「知道了」计,保证人不在也能照常弹卡。0 = 不弹框只用通知中心
+ "completion_dialog_seconds": 30,
+ # 弹出失败的重试次数与间隔(卡被 Finder / 桌面引用时,diskutil 会被 loginwindow 拒绝,
+ # 此时改走访达推出通道,再失败才重试)
+ "eject_retry_attempts": 5,
+ "eject_retry_interval": 15,
+ # 弹出失败时是否改走「访达推出」通道。
+ # 对付「卷被访达窗口 / 桌面引用、diskutil 卸载被 loginwindow 拒绝」很有效,
+ # 但 osascript 控制访达需要「自动化」权限,未授权时会弹授权框并**阻塞**,所以默认关闭。
+ # 想开:先在终端手动执行一次
+ # osascript -e 'tell application "Finder" to eject disk "卷名"'
+ # 在授权框点「允许」,再把这里改成 true。
+ "eject_finder_fallback": False,
+ # 不处理的卷名(系统盘等)
+ "deny_volumes": ["Macintosh HD", "Recovery", "TimeMachine", "VM", "Preboot", "Update"],
+ # ---- Windows 专属 ----
+ # 只把「可移动磁盘」当候选卷。这是硬安全线:绝不让程序碰到系统盘 / 数据盘。
+ # 极少数读卡器会被 Windows 识别为固定磁盘,那种机器上可改成 false。
+ "windows_only_removable": True,
+ # 卡为只读(NTFS 等)时是否降级为「只复制、不删源」
+ "allow_readonly_copy": True,
+ # 挂载稳定等待
+ "settle_seconds": 4,
+ "stability_checks": 3,
+ "stability_interval": 0.8,
+}
+
+
+def load_config() -> dict:
+ cfg = dict(DEFAULTS)
+ if CONFIG_PATH.exists():
+ try:
+ user = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
+ if isinstance(user, dict):
+ cfg.update(user)
+ except (json.JSONDecodeError, OSError):
+ pass
+ overrides = {
+ "SDIMPORT_TARGET_ROOT": "target_root",
+ "SDIMPORT_COUNTDOWN": "notify_countdown_seconds",
+ "SDIMPORT_EJECT_DELAY": "eject_delay_seconds",
+ "SDIMPORT_COMPLETION_DIALOG": "completion_dialog_seconds",
+ }
+ int_keys = {
+ "notify_countdown_seconds",
+ "eject_delay_seconds",
+ "completion_dialog_seconds",
+ }
+ for env_key, cfg_key in overrides.items():
+ raw = os.environ.get(env_key)
+ if raw:
+ cfg[cfg_key] = int(raw) if cfg_key in int_keys else raw
+ cfg["target_root"] = os.path.expanduser(str(cfg["target_root"]))
+ return cfg
+
+
+def ensure_dirs() -> None:
+ for d in (RUNTIME_DIR, LOG_DIR, STATE_DIR):
+ d.mkdir(parents=True, exist_ok=True)
+
+
+def write_default_config() -> Path:
+ """写出带注释性键名的默认配置(仅当文件不存在时)。"""
+ if CONFIG_PATH.exists():
+ return CONFIG_PATH
+ CONFIG_PATH.write_text(
+ json.dumps(DEFAULTS, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
+ )
+ return CONFIG_PATH
diff --git a/src/sdimport/pipeline.py b/src/sdimport/pipeline.py
new file mode 100644
index 0000000..5d0fbb2
--- /dev/null
+++ b/src/sdimport/pipeline.py
@@ -0,0 +1,468 @@
+"""主流程编排:找卡 -> 汇总 -> 倒计时确认 -> 拷贝校验 -> 删源 -> 汇报。"""
+
+from __future__ import annotations
+
+import time
+from dataclasses import dataclass, field
+from pathlib import Path
+
+from . import ejector, notify, planner, transfer
+from .journal import Journal, human_duration, human_size
+from .paths import ensure_dirs
+from .volumes import (
+ Volume,
+ check_writable,
+ find_videos,
+ list_candidate_volumes,
+ probe,
+ wait_until_stable,
+)
+
+PROGRESS_MIN_INTERVAL = 5.0
+
+
+def _sweep_existing(
+ job: CardJob,
+ skipped: list[planner.PlanItem],
+ cfg: dict,
+ journal: Journal,
+ result: RunResult,
+ sample_kb: int,
+ batch: int,
+) -> None:
+ """清理「此前已落盘、本次无需再拷」的卡内副本。
+
+ 场景:上一轮以「保留卡内文件」模式导入过,或导入中途卡被拔掉。
+ 此时目标文件已存在且一致,按「移动」语义应当把卡内副本清掉,否则卡永远腾不空。
+ 前提:必须通过完整校验(体积 + 头尾指纹),任何不一致一律不动。
+ """
+ if not skipped:
+ return
+ journal.step(f"{job.volume.name}:卡内有 {len(skipped)} 个文件此前已落盘,开始核对并清理")
+ cleaned = 0
+ for item in skipped:
+ ok, why = transfer.verify(item.src, item.dst, sample_kb)
+ if not ok:
+ journal.warn(f" {item.src.name}:与本地副本不一致({why}),保留卡内文件")
+ continue
+ journal.log(f" 删除清单:{item.src}")
+ ok, why = transfer.delete_source(item.src, item.dst, sample_kb)
+ if ok:
+ cleaned += 1
+ result.deleted += 1
+ else:
+ result.delete_failed += 1
+ result.errors.append(f"{item.src.name}: 清卡失败 {why}")
+ journal.warn(f" {item.src.name}:清卡失败(本地副本完好):{why}")
+ if cleaned and cleaned % batch == 0:
+ time.sleep(0.2)
+ journal.log(f" 核对完成:清理 {cleaned} 个,保留 {len(skipped) - cleaned} 个")
+
+
+@dataclass
+class RunOptions:
+ dry_run: bool = False
+ assume_yes: bool = False
+ no_delete: bool = False
+ target_volume: Path | None = None
+ quiet: bool = False
+
+
+@dataclass
+class CardJob:
+ volume: Volume
+ plan: planner.Plan
+ writable: bool = True
+
+
+@dataclass
+class RunResult:
+ imported: int = 0
+ skipped: int = 0
+ failed: int = 0
+ deleted: int = 0
+ delete_failed: int = 0
+ bytes_moved: int = 0
+ seconds: float = 0.0
+ aborted: bool = False
+ abort_reason: str = ""
+ cards: list[str] = field(default_factory=list)
+ dates: dict[str, int] = field(default_factory=dict)
+ errors: list[str] = field(default_factory=list)
+ # 本次真正动过的卡挂载点(用于导入完成后的延时自动弹出)
+ volumes: list[str] = field(default_factory=list)
+
+
+# --------------------------------------------------------------------------
+# 第一段:探测与规划
+# --------------------------------------------------------------------------
+
+
+def _report_tiny(files: list[Path], cfg: dict, journal: Journal) -> None:
+ """把体积极小的录像单独点出来,交用户自行判断。
+
+ 这些多半是误触或录制中断的短段,但**照样导入**。
+ 2026-09-14 真卡上有个 192KB / 约 50 秒的短段,一度被体积阈值静默滤掉 ——
+ 静默丢数据不可接受,宁可多导一个废片,也不能漏一个有效段。
+ """
+ limit = int(cfg.get("tiny_video_bytes", 0))
+ if limit <= 0:
+ return
+ tiny: list[tuple[Path, int]] = []
+ for f in files:
+ try:
+ size = f.stat().st_size
+ except OSError:
+ continue
+ if size < limit:
+ tiny.append((f, size))
+ if not tiny:
+ return
+ journal.warn(
+ f" 其中 {len(tiny)} 个体积极小(<{human_size(limit)}),疑似误触或中断的短录制,仍会一并导入:"
+ )
+ for f, size in tiny:
+ journal.warn(f" {f.name} {human_size(size)}")
+
+
+def discover_cards(cfg: dict, journal: Journal, opts: RunOptions) -> list[CardJob]:
+ """找出本次插着的记录仪卡,并生成导入计划。"""
+ if opts.target_volume is not None:
+ vol = probe(Path(opts.target_volume))
+ vols = [vol] if vol else []
+ if not vols:
+ journal.error(f"指定路径不是有效卷:{opts.target_volume}")
+ else:
+ vols = list_candidate_volumes(cfg)
+
+ journal.log(f"扫描到 {len(vols)} 个候选卷:" + (", ".join(v.describe() for v in vols) or "无"))
+
+ jobs: list[CardJob] = []
+ for vol in vols:
+ if not wait_until_stable(
+ vol.mount_point,
+ checks=int(cfg.get("stability_checks", 3)),
+ interval=float(cfg.get("stability_interval", 0.8)),
+ ):
+ journal.warn(f"{vol.name}:挂载未稳定,本次跳过")
+ continue
+ files = find_videos(vol, cfg, journal)
+ if not files:
+ journal.log(f"{vol.name}:无符合命名规范的录像,判定为非记录仪卡,跳过")
+ continue
+ writable = check_writable(vol.mount_point)
+ if not writable:
+ journal.warn(f"{vol.name}:卷为只读({vol.fs}),本次只能复制、无法清卡")
+ if not cfg.get("allow_readonly_copy", True):
+ continue
+ plan = planner.build_plan(vol, files, cfg, journal)
+ job = CardJob(volume=vol, plan=plan, writable=writable)
+ jobs.append(job)
+ journal.step(
+ f"{vol.name}:识别到录像 {len(files)} 个 / {human_size(plan.total_bytes)},"
+ f"其中新增 {len(plan.to_copy)} 个 / {human_size(plan.pending_bytes)},"
+ f"已导入过 {len(plan.skipped)} 个"
+ )
+ for date, items in plan.group_by_date().items():
+ journal.log(
+ f" -> {date}:{len(items)} 个 / {human_size(sum(i.size for i in items))}"
+ )
+ _report_tiny(files, cfg, journal)
+ return jobs
+
+
+def _confirm_message(jobs: list[CardJob], cfg: dict, timeout: int) -> tuple[str, str]:
+ total_files = sum(len(j.plan.to_copy) for j in jobs)
+ total_bytes = sum(j.plan.pending_bytes for j in jobs)
+ dates: dict[str, int] = {}
+ for job in jobs:
+ for date, items in job.plan.group_by_date().items():
+ dates[date] = dates.get(date, 0) + len(items)
+ date_txt = "、".join(f"{d}({n})" for d, n in sorted(dates.items()))
+
+ readonly_note = ""
+ if any(not j.writable for j in jobs):
+ readonly_note = "\n卡为只读,本次只复制、不清卡。"
+
+ title = "检测视频自动导入"
+ message = (
+ f"识别到 {len(jobs)} 张记录仪卡,共 {total_files} 个新视频 / {human_size(total_bytes)}。\n"
+ f"目标日期:{date_txt}\n"
+ f"落盘至:{cfg['target_root']}\n"
+ f"{timeout} 秒内未操作则自动开始导入,期间可点「取消导入」。{readonly_note}"
+ )
+ return title, message
+
+
+# --------------------------------------------------------------------------
+# 第二段:执行
+# --------------------------------------------------------------------------
+
+
+def _abort_item(
+ result: RunResult,
+ journal: Journal,
+ item: planner.PlanItem,
+ label: str,
+ kind: str,
+ detail: str,
+) -> None:
+ """单文件失败即中止整批:已拷成功的文件安全落盘,未拷的源文件一律不动,重插卡可续传。"""
+ result.failed += 1
+ result.aborted = True
+ result.abort_reason = f"{item.src.name} {kind}"
+ result.errors.append(f"{item.src.name}: {kind} {detail}")
+ journal.error(f" {label} -> {kind}:{detail};为保数据安全,本次中止且不删除任何源文件")
+
+
+def _run_one_card(
+ job: CardJob,
+ cfg: dict,
+ journal: Journal,
+ opts: RunOptions,
+ result: RunResult,
+ lock=None,
+) -> None:
+ plan = job.plan
+ chunk = max(64 * 1024, int(cfg.get("copy_chunk_mb", 8)) * 1024 * 1024)
+ sample_kb = int(cfg.get("verify_sample_kb", 64))
+ do_delete = (
+ bool(cfg.get("delete_source_after_verify", True))
+ and not opts.no_delete
+ and job.writable
+ )
+ batch = max(1, int(cfg.get("delete_batch_size", 10)))
+
+ items = plan.to_copy
+ result.skipped += len(plan.skipped)
+ if (items or (do_delete and plan.skipped)) and str(job.volume.mount_point) not in result.volumes:
+ result.volumes.append(str(job.volume.mount_point))
+ stale = transfer.cleanup_partials(plan.items)
+ if stale:
+ journal.log(f" 清理历史残留临时文件 {stale} 个(.part)")
+ if items:
+ journal.step(
+ f"{job.volume.name}:开始导入 {len(items)} 个文件({'清卡' if do_delete else '保留卡内文件'})"
+ )
+ else:
+ journal.step(f"{job.volume.name}:本次无新增视频")
+
+ if do_delete:
+ _sweep_existing(job, plan.skipped, cfg, journal, result, sample_kb, batch)
+
+ done_since_pause = 0
+ for idx, item in enumerate(items, 1):
+ if lock is not None:
+ lock.refresh()
+ label = f"[{idx}/{len(items)}] {item.src.name} ({human_size(item.size)})"
+
+ if not job.volume.mount_point.exists():
+ result.aborted = True
+ result.abort_reason = "卡已被拔出"
+ journal.error(f"{label} -> 卡已拔出,中止导入")
+ return
+
+ last_log = [0.0]
+
+ def on_progress(written: int, total: int, _label=label, _last=last_log) -> None:
+ now = time.time()
+ if now - _last[0] >= PROGRESS_MIN_INTERVAL:
+ _last[0] = now
+ pct = (written / total * 100) if total else 0
+ journal.log(f" {_label} 拷贝中 {pct:.0f}%({human_size(written)})")
+
+ try:
+ res = transfer.copy_file(item.src, item.dst, chunk, on_progress)
+ if not res.ok:
+ _abort_item(result, journal, item, label, "拷贝失败", res.error)
+ return
+
+ ok, why = transfer.verify(item.src, item.dst, sample_kb)
+ if not ok:
+ _abort_item(result, journal, item, label, "校验失败", why)
+ return
+
+ result.imported += 1
+ result.bytes_moved += res.size
+ result.dates[item.date] = result.dates.get(item.date, 0) + 1
+ journal.log(
+ f" {label} -> 完成,{human_size(res.size)},"
+ f"耗时 {human_duration(res.seconds)},均速 {human_size(int(res.speed))}/s,校验通过"
+ )
+
+ if do_delete:
+ journal.log(f" 删除清单:{item.src}")
+ ok2, why2 = transfer.delete_source(item.src, item.dst, sample_kb)
+ if ok2:
+ result.deleted += 1
+ journal.log(" 已删除卡内源文件")
+ else:
+ result.delete_failed += 1
+ result.errors.append(f"{item.src.name}: 删源失败 {why2}")
+ journal.warn(f" 删源失败(文件已安全落盘,卡内残留):{why2}")
+ done_since_pause += 1
+ if done_since_pause >= batch:
+ done_since_pause = 0
+ time.sleep(0.2)
+ except Exception as exc: # noqa: BLE001 - 兜底:未预期异常绝不清卡、绝不带崩后台服务
+ _abort_item(result, journal, item, label, "处理异常", repr(exc))
+ return
+
+ return
+
+
+def run(
+ cfg: dict,
+ journal: Journal,
+ opts: RunOptions | None = None,
+ lock=None,
+) -> RunResult:
+ opts = opts or RunOptions()
+ ensure_dirs()
+ result = RunResult()
+ t0 = time.time()
+
+ jobs = discover_cards(cfg, journal, opts)
+ result.cards = [j.volume.name for j in jobs]
+
+ if not jobs:
+ journal.log("本次未发现需要处理的记录仪卡")
+ result.seconds = time.time() - t0
+ return result
+
+ do_delete_global = (
+ bool(cfg.get("delete_source_after_verify", True)) and not opts.no_delete
+ )
+
+ pending_jobs = [j for j in jobs if j.plan.to_copy]
+ # 无新增、但卡内还留着「此前已落盘」的副本 => 只差清卡这一步
+ sweep_jobs = [
+ j
+ for j in jobs
+ if not j.plan.to_copy and do_delete_global and j.writable and j.plan.skipped
+ ]
+
+ if not pending_jobs and not sweep_jobs:
+ journal.log("卡已识别,所有录像此前均已导入且卡内无待清理副本,无动作")
+ result.skipped = sum(len(j.plan.skipped) for j in jobs)
+ result.seconds = time.time() - t0
+ if not opts.quiet and not opts.dry_run:
+ notify.notify(
+ "检测视频:无新增",
+ f"{'、'.join(result.cards)} 内有 {result.skipped} 个录像,此前均已导入。",
+ )
+ return result
+
+ work_jobs = pending_jobs + sweep_jobs
+ sweep_only = not pending_jobs and bool(sweep_jobs)
+
+ # 空间预检(仅新拷贝需要)
+ for job in pending_jobs:
+ ok, msg = planner.check_space(job.plan, cfg, journal)
+ journal.log(f"空间预检({job.volume.name}):{msg}")
+ if not ok:
+ result.aborted = True
+ result.abort_reason = "目标盘空间不足"
+ result.errors.append(msg)
+ journal.error(f"中止:{msg}")
+ notify.notify("检测视频:空间不足", msg)
+ result.seconds = time.time() - t0
+ return result
+
+ total_files = sum(len(j.plan.to_copy) for j in pending_jobs)
+ total_bytes = sum(j.plan.pending_bytes for j in pending_jobs)
+
+ if opts.dry_run:
+ if pending_jobs:
+ journal.step(
+ f"[演练模式] 将导入 {total_files} 个文件 / {human_size(total_bytes)},不做任何写入"
+ )
+ for job in pending_jobs:
+ for item in job.plan.to_copy:
+ journal.log(f" {item.src} -> {item.dst}")
+ for job in sweep_jobs:
+ journal.step(
+ f"[演练模式] {job.volume.name}:将核对并清理 {len(job.plan.skipped)} 个卡内副本"
+ )
+ result.seconds = time.time() - t0
+ return result
+
+ # 倒计时确认:只有「有新视频要导入」才打断用户;纯清卡静默执行
+ if pending_jobs:
+ if not opts.assume_yes:
+ timeout = int(cfg.get("notify_countdown_seconds", 60))
+ title, message = _confirm_message(pending_jobs, cfg, timeout)
+ verdict = notify.ask_with_countdown(title, message, timeout=timeout)
+ if verdict == "cancel":
+ journal.log("用户取消,本次不执行")
+ result.aborted = True
+ result.abort_reason = "用户取消"
+ result.seconds = time.time() - t0
+ return result
+ journal.log(f"确认结果:{verdict}(ok=点击确认或超时自动执行)")
+ else:
+ journal.step("已指定 --yes,跳过倒计时确认")
+
+ journal.step(
+ f"开始导入:{total_files} 个文件 / {human_size(total_bytes)},"
+ f"目标 {cfg['target_root']}"
+ )
+ notify.notify(
+ "检测视频:开始导入",
+ f"{total_files} 个视频 / {human_size(total_bytes)},正在拷贝,期间可继续用电脑。",
+ )
+ elif sweep_only:
+ journal.step("卡内无新视频,仅执行清卡核对(不打断用户)")
+
+ for job in work_jobs:
+ _run_one_card(job, cfg, journal, opts, result, lock=lock)
+ if result.aborted:
+ break
+
+ result.seconds = time.time() - t0
+
+ # 结果汇报
+ if result.aborted:
+ notify.notify(
+ "检测视频:导入中断",
+ f"已成功 {result.imported} 个 / {human_size(result.bytes_moved)},"
+ f"原因:{result.abort_reason}。卡内未成功导入的文件已保留,重新插卡即可续传。",
+ )
+ journal.error(f"导入中断:{result.abort_reason}")
+ elif result.imported or (result.deleted and not sweep_only):
+ date_txt = "、".join(f"{d}({n})" for d, n in sorted(result.dates.items()))
+ if result.delete_failed:
+ tail = f"有 {result.delete_failed} 个卡内文件未能删除,仍有残留。"
+ elif result.deleted:
+ tail = f"卡内 {result.deleted} 个文件已清空。"
+ else:
+ tail = "卡内源文件已保留。"
+ eject_note = ""
+ if cfg.get("eject_after_import", True) and result.volumes and not opts.dry_run:
+ delay = max(10, int(cfg.get("eject_delay_seconds", 300)))
+ eject_note = f" {ejector.delay_text(delay)}自动弹出 SD 卡。"
+ notify.notify(
+ "检测视频:导出完成",
+ f"{result.imported} 个视频 / {human_size(result.bytes_moved)} / "
+ f"耗时 {human_duration(result.seconds)}。{tail}{eject_note}",
+ subtitle=date_txt,
+ )
+ elif sweep_only and not opts.quiet:
+ notify.notify(
+ "检测视频:卡内已清空",
+ f"卡内 {result.deleted} 个已导入文件核对通过并清理完毕。",
+ )
+
+ if result.imported or result.deleted:
+ journal.step(
+ f"完成:导入 {result.imported} 个 / {human_size(result.bytes_moved)} / "
+ f"耗时 {human_duration(result.seconds)};清卡 {result.deleted} 个,"
+ f"清卡失败 {result.delete_failed}"
+ )
+
+ # 导出完成 -> 明确交代结果 -> 延时自动弹出卡。
+ # 中止时不安排弹出:卡里还有没导完的东西,用户多半要重插重试。
+ if not result.aborted and not opts.dry_run and (result.imported or result.deleted):
+ ejector.offer_and_schedule(cfg, journal, result, [Path(v) for v in result.volumes])
+
+ return result
diff --git a/src/sdimport/planner.py b/src/sdimport/planner.py
new file mode 100644
index 0000000..c65b1cb
--- /dev/null
+++ b/src/sdimport/planner.py
@@ -0,0 +1,158 @@
+"""导入规划:按文件名日期分组、幂等判定、空间预检。
+
+纪律(来自实战教训):
+ 1. 日期一律取文件名里的 YYYYMMDD,不看卡上的目录名(导入时目录名常与拍摄日期错位)。
+ 2. 目标已存在且体积一致 => 视为已导入,跳过,不覆盖、不改名。
+ 3. 体积不一致 => 判为冲突,改名保存并告警,绝不覆盖既有文件。
+"""
+
+from __future__ import annotations
+
+import re
+from dataclasses import dataclass, field
+from pathlib import Path
+
+from .journal import human_size
+from .volumes import Volume
+
+COPY = "copy"
+SKIP = "skip"
+
+
+@dataclass
+class PlanItem:
+ src: Path
+ dst: Path
+ date: str
+ size: int
+ action: str
+ reason: str = ""
+
+
+@dataclass
+class Plan:
+ volume: Volume
+ items: list[PlanItem] = field(default_factory=list)
+ conflicts: list[PlanItem] = field(default_factory=list)
+
+ @property
+ def to_copy(self) -> list[PlanItem]:
+ return [i for i in self.items if i.action == COPY]
+
+ @property
+ def skipped(self) -> list[PlanItem]:
+ return [i for i in self.items if i.action == SKIP]
+
+ @property
+ def pending_bytes(self) -> int:
+ return sum(i.size for i in self.to_copy)
+
+ @property
+ def total_bytes(self) -> int:
+ return sum(i.size for i in self.items)
+
+ def dates(self) -> list[str]:
+ return sorted({i.date for i in self.to_copy})
+
+ def group_by_date(self) -> dict[str, list[PlanItem]]:
+ out: dict[str, list[PlanItem]] = {}
+ for item in self.to_copy:
+ out.setdefault(item.date, []).append(item)
+ return {k: sorted(v, key=lambda x: x.src.name) for k, v in sorted(out.items())}
+
+
+def parse_date(filename: str, cfg: dict) -> str | None:
+ """从文件名提取 YYYYMMDD。"""
+ flags = re.IGNORECASE if cfg.get("case_insensitive", True) else 0
+ try:
+ pattern = re.compile(str(cfg["filename_regex"]), flags)
+ except re.error:
+ return None
+ m = pattern.match(filename)
+ if not m or not m.groups():
+ return None
+ raw = m.group(1)
+ if len(raw) != 8 or not raw.isdigit():
+ return None
+ y, mo, d = int(raw[:4]), int(raw[4:6]), int(raw[6:8])
+ if not (1990 <= y <= 2200 and 1 <= mo <= 12 and 1 <= d <= 31):
+ return None
+ return raw
+
+
+def _unique_dest(dest: Path) -> Path:
+ stem, suffix = dest.stem, dest.suffix
+ for n in range(2, 1000):
+ cand = dest.with_name(f"{stem} ({n}){suffix}")
+ if not cand.exists():
+ return cand
+ return dest.with_name(f"{stem} ({dest.stat().st_mtime_ns}){suffix}")
+
+
+def build_plan(volume: Volume, files: list[Path], cfg: dict, journal=None) -> Plan:
+ root = Path(cfg["target_root"])
+ plan = Plan(volume=volume)
+
+ for src in files:
+ date = parse_date(src.name, cfg)
+ if date is None:
+ if journal:
+ journal.warn(f"文件名无法解析日期,跳过:{src.name}")
+ continue
+ try:
+ size = src.stat().st_size
+ except OSError as exc:
+ if journal:
+ journal.warn(f"读取失败,跳过:{src}({exc})")
+ continue
+
+ dest_dir = root / date
+ dest = dest_dir / src.name
+ if dest.exists():
+ try:
+ same = dest.stat().st_size == size
+ except OSError:
+ same = False
+ if same:
+ plan.items.append(
+ PlanItem(src, dest, date, size, SKIP, "目标已存在且体积一致")
+ )
+ continue
+ new_dest = _unique_dest(dest)
+ item = PlanItem(src, new_dest, date, size, COPY, "同名但体积不同,改用新名")
+ plan.items.append(item)
+ plan.conflicts.append(item)
+ if journal:
+ journal.warn(
+ f"冲突:{dest.name} 已存在且体积不符"
+ f"(本地 {human_size(dest.stat().st_size)} vs 卡上 {human_size(size)}),"
+ f"本次落为 {new_dest.name}"
+ )
+ continue
+
+ plan.items.append(PlanItem(src, dest, date, size, COPY, "新增"))
+
+ return plan
+
+
+def check_space(plan: Plan, cfg: dict, journal=None) -> tuple[bool, str]:
+ """目标盘剩余空间是否够用。"""
+ root = Path(cfg["target_root"])
+ probe = root
+ while not probe.exists() and probe.parent != probe:
+ probe = probe.parent
+ import shutil
+
+ try:
+ free = shutil.disk_usage(probe).free
+ except OSError as exc:
+ return False, f"无法读取目标盘剩余空间:{exc}"
+
+ need = int(plan.pending_bytes * float(cfg.get("min_free_space_ratio", 1.10)))
+ if free < need:
+ return (
+ False,
+ f"空间不足:待导入 {human_size(plan.pending_bytes)},"
+ f"需预留 {human_size(need)},目标盘可用 {human_size(free)}",
+ )
+ return True, f"空间充足:待导入 {human_size(plan.pending_bytes)},可用 {human_size(free)}"
diff --git a/src/sdimport/platforms/__init__.py b/src/sdimport/platforms/__init__.py
new file mode 100644
index 0000000..dc52e5a
--- /dev/null
+++ b/src/sdimport/platforms/__init__.py
@@ -0,0 +1,22 @@
+"""按运行平台自动选择实现。
+
+用法:`from .platforms import current, Volume`
+"""
+
+from __future__ import annotations
+
+import sys
+
+from .base import Platform, ServiceStatus, Volume
+
+if sys.platform == "darwin":
+ from .macos import MacPlatform as _Impl
+elif sys.platform.startswith("win"):
+ from .windows import WindowsPlatform as _Impl
+else:
+ from .generic import GenericPlatform as _Impl
+
+#: 当前平台的实现实例
+current: Platform = _Impl()
+
+__all__ = ["Platform", "ServiceStatus", "Volume", "current"]
diff --git a/src/sdimport/platforms/base.py b/src/sdimport/platforms/base.py
new file mode 100644
index 0000000..5cc0400
--- /dev/null
+++ b/src/sdimport/platforms/base.py
@@ -0,0 +1,207 @@
+"""平台适配层:接口定义与跨平台数据结构。
+
+核心逻辑(按日期分组、拷贝、校验、清卡、编排)完全不关心操作系统。
+所有系统差异都收敛到这里定义的四组动作:
+
+ 1. **卷** —— 枚举候选卷、读卷信息、判断可写、等挂载稳定
+ 2. **通知** —— 发通知、两种带倒计时的确认框
+ 3. **弹出** —— 把可移动卷弹出来
+ 4. **服务** —— 安装 / 卸载 / 查询后台服务
+
+各平台实现见 macos.py / windows.py;运行时由 platforms.current 自动选。
+接口里能跨平台实现的(写测试、等稳定)直接在基类给出,子类只实现真正有差异的部分。
+"""
+
+from __future__ import annotations
+
+import os
+import shutil
+import time
+from dataclasses import dataclass
+from pathlib import Path
+
+# 等待挂载稳定时的默认节拍
+DEFAULT_STABILITY_CHECKS = 3
+DEFAULT_STABILITY_INTERVAL = 0.8
+
+
+@dataclass
+class Volume:
+ """一个候选卷。macOS 上是 /Volumes/<卷名>,Windows 上是 E:\\。"""
+
+ name: str
+ mount_point: Path
+ uuid: str = ""
+ fs: str = ""
+ readonly: bool = False
+ removable: bool = False
+ bus: str = ""
+ total_bytes: int = 0
+ free_bytes: int = 0
+
+ @property
+ def identity(self) -> str:
+ """跨插拔稳定的标识,用于状态去重。"""
+ return self.uuid or f"{self.name}::{self.mount_point}"
+
+ def describe(self) -> str:
+ kind = self.bus or self.fs or "?"
+ ro = "只读" if self.readonly else "可写"
+ return f"{self.name}({kind}, {ro})"
+
+
+@dataclass
+class ServiceStatus:
+ """后台服务状态。
+
+ installed:服务**当前**是否处于已安装状态(卸载成功后应为 False)
+ ok :**本次操作**本身是否成功(用于让调用方判断退出码)
+ """
+
+ installed: bool
+ detail: str = ""
+ ok: bool = True
+
+
+class Platform:
+ """平台能力接口。子类只需覆盖真正有差异的方法。"""
+
+ name = "generic"
+ # 该平台是否靠「常驻进程」实现插卡触发。
+ # macOS 用 launchd 的 WatchPaths + StartInterval,无需常驻;Windows 需要常驻。
+ needs_daemon = False
+
+ # ------------------------------------------------------------------
+ # 1. 卷
+ # ------------------------------------------------------------------
+
+ def list_volumes(self, cfg: dict) -> list[Volume]:
+ raise NotImplementedError
+
+ def probe_volume(self, mount_point: Path) -> Volume | None:
+ raise NotImplementedError
+
+ def default_mount_root(self) -> Path | None:
+ """候选卷所在的位置(macOS 是 /Volumes,Windows 无固定根,返回 None)。"""
+ return None
+
+ def volume_names(self, cfg: dict) -> list[str]:
+ """当前候选卷的**轻量**名字快照,用于判断「是不是新插了一张卡」。
+
+ 必须轻量:兜底触发每 30~120 秒就会调一次,不能每次都去问系统要卷详情。
+ 子类应覆写成不调慢命令的版本;默认实现退化到完整枚举。
+ """
+ return sorted(v.name for v in self.list_volumes(cfg))
+
+ def check_writable(self, mount_point: Path) -> bool:
+ """真实写测试,比读只读标记可靠。"""
+ probe_file = Path(mount_point) / ".sdimport_write_test"
+ try:
+ probe_file.write_bytes(b"ok")
+ probe_file.unlink()
+ return True
+ except OSError:
+ return False
+
+ def wait_until_stable(
+ self,
+ mount_point: Path,
+ checks: int = DEFAULT_STABILITY_CHECKS,
+ interval: float = DEFAULT_STABILITY_INTERVAL,
+ ) -> bool:
+ """等到卷挂载稳定。
+
+ 判据只用**卷总容量**:它在挂载完成后固定。
+ 不能用可用空间 / 文件数 —— 系统后台写入会让这些值持续变动,
+ 永远等不到相等(macOS 上实测踩过:整个等待窗口跑满仍未稳定)。
+ shutil.disk_usage 两端都可用,故放在基类。
+ """
+ mount_point = Path(mount_point)
+ last_total = None
+ stable = 0
+ deadline = time.time() + checks * interval * 6 + 5
+ while time.time() < deadline:
+ try:
+ usage = shutil.disk_usage(mount_point)
+ if usage.total <= 0:
+ raise OSError("卷容量为 0,可能仍在挂载")
+ os.listdir(mount_point)
+ except OSError:
+ last_total, stable = None, 0
+ time.sleep(interval)
+ continue
+ if usage.total == last_total:
+ stable += 1
+ if stable >= checks:
+ return True
+ else:
+ last_total, stable = usage.total, 0
+ time.sleep(interval)
+ return False
+
+ # ------------------------------------------------------------------
+ # 2. 通知
+ # ------------------------------------------------------------------
+
+ def notify(self, title: str, message: str, subtitle: str = "", sound: str = "") -> bool:
+ raise NotImplementedError
+
+ def ask_countdown(
+ self,
+ title: str,
+ message: str,
+ timeout: int,
+ ok_label: str = "立即开始",
+ cancel_label: str = "取消导入",
+ ) -> str:
+ """开始导入前的确认框。
+
+ 返回 'ok'(点了确认,或超时未操作)/'cancel'(明确取消)/'error'(弹不出来)。
+ 超时必须按「同意」算 —— 用户要的是「不响应就执行」。
+ """
+ raise NotImplementedError
+
+ def ask_completion(
+ self,
+ title: str,
+ message: str,
+ timeout: int = 30,
+ keep_label: str = "暂不弹出",
+ ok_label: str = "知道了",
+ ) -> str:
+ """导出完成提示框。
+
+ 返回 'ok'(继续弹出计划)/'keep'(明确选了暂不弹出)/'error'(弹不出来)。
+ """
+ raise NotImplementedError
+
+ # ------------------------------------------------------------------
+ # 3. 弹出
+ # ------------------------------------------------------------------
+
+ def eject(
+ self,
+ mount_point: Path,
+ attempts: int,
+ interval: float,
+ use_fallback: bool = False,
+ ) -> tuple[bool, str]:
+ """弹出可移动卷。只弹出,绝不删除任何文件。"""
+ raise NotImplementedError
+
+ # ------------------------------------------------------------------
+ # 4. 后台服务
+ # ------------------------------------------------------------------
+
+ def install_service(self, project_root: Path) -> ServiceStatus:
+ raise NotImplementedError
+
+ def uninstall_service(self, project_root: Path) -> ServiceStatus:
+ raise NotImplementedError
+
+ def service_status(self) -> ServiceStatus:
+ raise NotImplementedError
+
+ def daemon_argv(self, project_root: Path) -> list[str]:
+ """常驻进程的启动命令(needs_daemon 为 True 的平台才有意义)。"""
+ raise NotImplementedError
diff --git a/src/sdimport/platforms/generic.py b/src/sdimport/platforms/generic.py
new file mode 100644
index 0000000..06196e8
--- /dev/null
+++ b/src/sdimport/platforms/generic.py
@@ -0,0 +1,136 @@
+"""兜底平台实现(非 macOS、非 Windows)。
+
+只为保证程序能启动并给出可读提示,不假装支持。
+Linux 上顺手支持常见挂载点与 notify-send / umount。
+"""
+
+from __future__ import annotations
+
+import os
+import shutil
+import subprocess
+import time
+from pathlib import Path
+
+from .base import Platform, ServiceStatus, Volume
+
+LINUX_MOUNT_ROOTS = ("/media", "/run/media", "/mnt")
+
+
+def _iter_mount_dirs():
+ for root in LINUX_MOUNT_ROOTS:
+ base = Path(root)
+ if not base.is_dir():
+ continue
+ try:
+ entries = sorted(base.iterdir())
+ except OSError:
+ continue
+ for entry in entries:
+ if not entry.is_dir() or entry.is_symlink():
+ continue
+ yield entry
+ # /run/media//