-
Notifications
You must be signed in to change notification settings - Fork 0
Core Sync Engine
位于 src/app_service_core.py,AppService 管理整个同步生命周期。由 main.py 在配置加载和 API 验证后实例化。
# main.py
app = AppService(config, db, admin_client)创建并初始化所有子服务和状态:
AppService.__init__()
├── 存储 config、db、admin_api 引用
├── 创建 RefreshService(self)
├── 初始化锁基础设施:
│ ├── _path_locks_lock (获取 path_lock 时的外层锁)
│ ├── _path_locks (dict[str, threading.Lock])
│ ├── _dav_write_lock (threading.Lock)
│ ├── _cleanup_lock + _pending_cleanups
│ ├── _restoring_lock + _restoring_markers
│ ├── _restoring_generation (代际计数器)
│ ├── _lineage_log_lock + _lineage_log_keys
│ ├── _engine_internal_markers + _engine_internal_generation
│ ├── _fingerprint_locks_lock (按指纹锁的字典锁)
│ ├── _fingerprint_locks (按指纹串行化)
│ ├── _webdav_scan_logged (WebDAV 扫描日志去重集合)
│ └── _refresh_lock (WebUI 媒体刷新锁)
├── 解析 A/B/C 根路径
├── 创建 SyncService(self)
└── 创建 SubtitleHandler(self)
注:
init_subtitle_table()在Database.__init__()中调用,不在AppService.__init__()。AppService.start()中调用的是cleanup_invalid_subtitles()。
9 步初始化过程:
-
准备环境并初始化数据库 — 检查 A 区路径存在性(不存在则 warning),创建 B/C 目录(如需要),初始化 bridge.db 所有表
-
从 OpenList API 加载引擎配置 — 使用
StrmStorageManager获取所有driver=strm的存储节点,解析additionJSON 字段提取SaveStrmLocalPath、paths、SaveLocalMode。仅过滤用户配置的引擎,构建映射:引擎挂载点 → A 区本地路径 → 监控云端路径 -
B 区物理磁盘逆向自同步(
initial_scan_b(),拆分为 4 个子函数):-
_scan_b_disk()— 遍历 B 区磁盘,计算每个.strm的指纹 -
_load_b_db_records()— 加载数据库b_strm_files表记录 -
_reconcile_b_historical_records()— 对比历史 DB 记录与磁盘数据- 新文件(磁盘有但 DB 无):注册、检查血统、加入身份跟踪
- 失效记录(DB 有但磁盘无且无同义路径):清理
-
改名文件(DB 有但磁盘无,同义路径存在):自动
move_b_record - 损坏文件(STRM 内容为空/损坏):从 A 区恢复
-
_insert_new_b_records()— 插入磁盘上新的 B 区记录
-
-
同步受保护根目录并检测移除的根目录 — 读取 DB 的
protected_roots,对比 API 返回的当前引擎路径。此前存在但 API 不再返回的根目录 → 迁移到 C 区 -
持久化当前根目录快照 — 将当前引擎路径写入
protected_roots_snapshot表 -
A 区全量扫描与索引建立 — 遍历所有 A 区目录,解析
.strm文件内容,计算指纹,注册a_strm_files表。发现字幕文件(.ass/.srt/.ssa)路由到SubtitleHandler -
A → B 全量同步(可选,受
sync_on_startup配置控制,方法scan_a_to_b_full_sync) — 对每个 A 区记录,检查指纹是否已在 B 区。不在时复制 STRM 到 B 区并注册。已存在时跳过(防止劣质命名回灌)。当sync_on_startup = false时跳过此步骤(日志输出"跳过 A→B 全量同步"),但启动等待仍然执行。 -
B 区冗余清理 — 删除状态为
duplicate、quarantined、invalid的文件。清理空目录(保留含.nfo、.jpg、.png等刮削元数据的目录) -
启动 Watchdog 监控与刷新定时器 — 创建
watchdog.Observer及三个事件处理器,启动RefreshService定时器
- 取消所有待执行的延迟清理定时器(
_pending_cleanups) - 停止 RefreshService 定时器
- 停止 Watchdog 观察者并等待线程退出
注:
stop()不关闭数据库连接,不设置_running标志。数据库生命周期由Database类独立管理。
核心复制操作:
-
指纹计算(
utils/strm_utils.py:make_strm_fingerprint): 读取 STRM 文件内容(WebDAV URL),规范化(去除查询参数、小写化),计算hashlib.sha256(url.encode()).hexdigest() -
血统验证(
_verify_b_path_lineage,9 步管线):-
_resolve_a_source— 解析 A 区源文件 -
_check_basic_lineage— 基础路径层级一致检查 -
_check_season_layer_addition— B 区自动添加 Season 层级检查 -
_extract_media_names_from_path_parts+_check_media_name_match— 媒体名称匹配 -
_resolve_cloud_and_physical_names— 引擎配置与云端/物理名称解析 -
_check_boundary_files— 越界文件检查 -
_check_boundary_mappings— 边界映射匹配检查 -
_handle_sync_phase_boundary— 同步阶段边界记录(仅is_sync_phase=True时执行) -
_check_solo_episode— 单集/批量检测(间接触发trigger_delayed_solo_check30 秒观察定时器)
-
-
媒体类型检测(
media_renamer.py):- 番剧:提取季集,构建
Season XX/S01E01.strm路径 - 电影:保留原文件名,复制到相同相对路径
- 番剧:提取季集,构建
-
数据库注册:
- 写入
b_strm_files记录(fingerprint、webdav_path、status='valid') - 写入
strm_identity记录(fingerprint → B 路径映射)
- 写入
由 watchdog 在 A 区文件创建或修改时触发:
- 判断文件类型(
.strm或字幕.ass/.srt/.ssa) - 字幕:路由到
SubtitleHandler.process_subtitle_file() - STRM:解析 WebDAV 路径,计算指纹
-
获取指纹锁(
get_fingerprint_lock(fingerprint))— 按指纹串行化,避免并发创建 B 实例的 TOCTOU 竞争 - 在指纹锁内:检查 identity 表
- STRM 指纹不在 B 区:通过
SyncService复制 - STRM 指纹已在 B 区:检查现有文件是否损坏 → 恢复
A 区文件被删除时触发:
- 检查文件是否仍在磁盘上(仍在则跳过,可能是 openlist 引擎先删后建的操作)
- 在
a_strm_files表中查找记录 - 删除 A 区 DB 记录
- 如果找到记录,触发
trigger_delayed_cleanup(parent_webdav_path)安排延迟清理
注:不传播删除到 B 区。B 区清理由
trigger_delayed_cleanup异步协调。
新 .strm 出现在 B 区时触发:
- 计算指纹
- 血统验证(9 步管线
_verify_b_path_lineage) - 血统失败:调用
_restore_b_from_a_after_violation()(物理删除越界文件 → 从 A 区恢复到正确位置),而非设invalid状态 - 无法解析 STRM:走
_handle_unparseable_strm()分支 - 重复指纹:重命名为
.duplicate - 有效新文件:注册 DB,加入身份跟踪
用户删除 B 区 .strm 时触发,有三重安全机制防止误删云端文件:
- 路径锁(
get_path_lock) - 查找 DB 记录(fingerprint、webdav_path 等)
-
第一重:
_restoring_markers检查 — 如果 fingerprint 在程序恢复标记集合中,跳过追删 -
第二重:
_engine_internal_markers检查(B-7 标记)— 如果是程序内部删除(隔离/去重/迁移),跳过云端删除,仅清理本地 DB 记录 -
第三重:
has_other_b_instance+_check_fingerprint_exists_in_b— 如果 DB 或文件系统中仍存在同指纹的其他 B 区实例,跳过 WebDAV 删除 - 三重全不通过,执行云端删除:
- MOVE 模式:通过
build_webdav_trash_path()递归创建回收站目录树,调用admin_api.move(),触发刷新钩子 - DELETE 模式:调用
admin_api.remove(),触发刷新钩子
- MOVE 模式:通过
- 刷新钩子导致 OpenList 重新生成 STRM → A 区文件被删除
- 清理 B 区 DB 记录和身份跟踪
用户重命名或移动 B 区 .strm 时触发:
- 路径规范化(
Path.resolve()) - 双路径锁(按路径 key 字典序获取,避免死锁)
- 在锁内调用
db.move_b_record()更新 DB 记录(local_path) - 读取新位置的 STRM 内容 → 计算指纹 → 刷新
strm_identity的 current_b_path
注:
handle_b_moved本身不做血统验证,也不启动 30 秒观察定时器。30 秒观察由_check_solo_episode(在_verify_b_path_lineage管线第 9 步)间接触发trigger_delayed_solo_check。
后台线程周期调用 app.refresh_webdav_root()。工作线程每次循环重新读取间隔值,使 WebUI 热重载后的新间隔在下个周期生效:
def _worker(self) -> None:
self.execute_refresh_cycle()
while self._running:
interval = self.app.config.refresh.interval_seconds
waited = 0
while self._running and waited < interval:
time.sleep(1)
waited += 1
if not self._running:
break
self.execute_refresh_cycle()每次刷新周期前将所有路径分为三类:
| 类别 | 说明 | 处理模式 |
|---|---|---|
valid_refresh_paths |
既在引擎管辖又在刷新列表 | 完整模式(刷新+清理) |
only_refresh |
仅在刷新列表,不在引擎管辖 | 只读模式(仅刷新,不清理 B 区) |
only_engine |
仅在引擎管辖,不在刷新列表 | 不参与本次刷新 |
| 步骤 | 方法 | 说明 |
|---|---|---|
| 1 | _sync_and_scan_protected_roots |
同步并扫描受保护根目录(与 DB 快照对比) |
| 2 |
_analyze_paths + _log_path_analysis
|
路径分析(交叉校验 refresh_paths vs strm_engine_paths) |
| 3 | _check_engine_accessibility |
通过 Admin API 验证每个引擎存储状态 |
| 4 | _cleanup_a_for_update_mode |
Update 模式 A 区清理(清理云端已不存在的 A 区残留 STRM) |
| 5 | _calculate_safe_refresh_paths |
计算安全刷新路径(valid_refresh_paths 与 accessible_engines 交集) |
| 6 | _execute_webdav_refreshes |
对安全路径调用 trigger_refresh_via_fs_list()
|
| 7 | _wait_for_sync |
等待同步落地(睡眠 a_to_b_restore_delay_seconds,默认 30s) |
| 8 | _scan_and_sync |
扫描与同步(initial_scan_a() → scan_a_to_b_full_sync()) |
| 9 | _persist_snapshot |
持久化根目录快照(写入 protected_roots_snapshot) |
每个候选清理文件必须通过三层检查,任一层通过即保留:
-
幽灵保护检查:检查
ghost_protection表,expire_time > now()时保留 - A 区源存在性检查:A 区仍有对应 STRM 文件时保留(引擎仍在生成)
-
WebDAV 存在性检查:通过
HEAD/GET验证云端文件真实存在时保留
仅三层全不通过才执行物理删除。
以下是本文件中尚未详细描述但实际存在的重要方法和类:
-
StrmStorageManager(app_service_core.py)— 通过 Admin API 获取所有driver=strm的存储节点,解析additionJSON 提取路径映射。 -
StrmStorageInfo(app_service_core.py,frozen dataclass,5 字段:id/mount_path/status/paths/save_local_mode)— 存储节点信息快照,与config.py中的StrmStorageMapping(3 字段:mount_path/paths/local_path)不同。
-
get_webdav_lock(namespace)— 命名空间隔离的 WebDAV 操作锁,防止不同引擎/路径的并发冲突。 -
_refresh_lock— 刷新周期互斥锁,防止并发刷新。 -
get_fingerprint_lock(fingerprint)— 按指纹创建/复用锁,串行化同一指纹的并发创建操作。
-
_restore_b_from_a_after_violation(local, webdav_path, fingerprint)— 血统越界后恢复:物理删除越界文件 → 从 A 区复制到正确位置 → 更新 DB 记录。 -
_force_delete_and_verify(path)— 强制删除文件并验证删除是否成功。 -
_handle_b_zombie(path)— 处理 B 区僵尸文件(DB 记录存在但磁盘文件已消失)。 -
cleanup_a_deleted_on_cloud(webdav_path)— 清理云端已删除的 A 区残留记录。 -
handle_b_renamed_to_non_strm(src_path, dest_path)— B 区.strm被重命名为非.strm扩展名时的处理。 -
ensure_single_visible_instance(prefer_path)— 确保同一指纹仅一个valid实例可见,其余改为.duplicate。
-
RefreshService(refresh_service.py)— 后台线程周期刷新,内建熔断器:连续失败次数过多时自动暂停刷新,避免无意义的网络请求。refresh_healthy/refresh_consecutive_failures/refresh_last_error通过/api/main/status暴露。 -
refresh_webdav_root_readonly()— 只读模式刷新(不清理 B 区),用于非引擎管辖路径。
-
app_service.py(非app_service_core.py)— 兼容重导出层,将AppService等核心符号重新导出,供旧模块路径引用。
🏡 返回 Wiki 首页 • 💻 项目源码仓库 • 🐛 提交 Bug / 建议 • 📦 下载最新版本
🚨 安全与自保黄金法则(每页必读)
- 严禁随意重置 OpenList 令牌:播放签名(
?sign=)强依赖服务端密钥。一旦重置,B区所有.strm将瞬间失效报无权播放,只能清库重来!- 调试阶段切勿使用 DELETE:
DELETE会物理删除云端文件,极其危险!建议终身配置为action = "MOVE"(云端一比一树状回收站模式)。- 放心刮削,资产安全:空文件夹清理算法采用严格的零物理文件判定,含有海报图片、
.nfo、外部字幕的目录绝对不会被误删。
本项目遵循 MIT 开源协议。数据无价,请在充分测试后接入生产环境。