尧图精选

LibrePhotos 缺失照片(Missing Photos)机制深度解析:检测、清理与哈希重关联全流程

🕒 发布时间:2026/9/16 23:28:51 📁 来源:尧图网络
LibrePhotos 缺失照片Missing Photos机制深度解析检测、清理与哈希重关联全流程【免费下载链接】librephotosA self-hosted open source photo management service.项目地址: https://gitcode.com/GitHub_Trending/li/librephotos本篇文章基于 LibrePhotos 仓库中 missing-photos.md 开发文档展开完整解析缺失照片功能的架构设计与实现细节当磁盘上的照片文件消失、而数据库中的元数据仍然保留时系统如何通过File.missing标记、Photo._check_files()检测、后台 Job 清理以及基于内容哈希的自动重关联来维持图库一致性。读完本文你将掌握缺失照片的判定规则、两个核心 Job 的实现原理、POST /api/deletemissingphotos接口的工作方式、前端展示逻辑以及相关的测试方法与已知遗留问题。功能概述什么是缺失照片在 LibrePhotos 中缺失照片指这样一种状态文件系统上的照片文件已经不可用被移动、删除、网络存储掉线等但数据库中的元数据记录仍然存在。文件消失后照片的缩略图、人脸标注、相册归属、评分等元数据不应立即丢失——这为用户保留了恢复的可能也让系统能够感知文件状态的变化。因此 LibrePhotos 采用软删除 标记策略文件记录不立即从数据库删除而是打上missing标记照片本身在满足特定条件时被归入缺失照片集合等待用户确认后统一清理。文件一旦重新出现例如网络盘重新挂载、文件被移回扫描目录系统又能通过哈希自动重关联回原有的元数据不会产生重复照片。数据模型缺失状态如何表达File 模型磁盘文件的唯一记录File模型定义在 apps/backend/api/models/file.py代表磁盘上的一个文件class File(models.Model): hash models.CharField(primary_keyTrue, max_length64) path models.TextField(blankTrue, default, uniqueTrue) type models.PositiveIntegerField(choicesFILE_TYPES) missing models.BooleanField(defaultFalse) # Tracks if file is missing embedded_media models.ManyToManyField(self, symmetricalFalse)关键字段说明字段类型与约束作用hashCharField(primary_keyTrue, max_length64)文件内容 MD5 用户 ID 拼接而成是主键pathTextField(blankTrue, default, uniqueTrue)文件的完整文件系统路径uniqueTrue约束是承重设计missingBooleanField(defaultFalse)标记文件当前是否在磁盘上找不到typePositiveIntegerField(choicesFILE_TYPES)文件类型IMAGE(1)、VIDEO(2)、METADATA_FILE(3)、RAW_FILE(4)、UNKNOWN(5)path的uniqueTrue约束之所以承重是因为File.create()apps/backend/api/models/file.py先按path查重、命中则直接复用已有记录而不是新建一行。这使得同一路径的文件重新出现时数据库不会产生重复的File行existing File.objects.filter(pathpath).first() if existing: # 该文件此前被标记为缺失但此刻已回到磁盘 if existing.missing and os.path.exists(path): existing.missing False existing.save(update_fields[missing]) return existing注意File.create还会处理并发竞态若在两个线程同时创建同一路径时抛出IntegrityError它会先按path再按hash回查并复用已存在的记录apps/backend/api/models/file.py。Photo 模型照片与文件的关联Photo模型定义在 apps/backend/api/models/photo.py与File的关系如下class Photo(models.Model): # UUID 主键——支持灵活的资产管理 id models.UUIDField(primary_keyTrue, defaultuuid.uuid4, editableFalse) # 内容哈希用于去重文件内容 MD5 用户 ID image_hash models.CharField(max_length64, db_indexTrue) files models.ManyToManyField(File) # 所有关联文件 main_file models.ForeignKey( File, related_namemain_photo, on_deletemodels.SET_NULL, blankFalse, nullTrue, ) # ... 其他字段两个关键点主键是 UUIDid不是image_hash。image_hash是带索引但非唯一的CharField数据库中既没有uniqueTrue也没有unique_together或UniqueConstraint。所谓每个用户哈希唯一是哈希构造方式内容 MD5 用户 ID保证的性质而不是数据库约束。因此查询照片必须写成Photo.objects.filter(image_hashhash, owneruser)绝不能Photo.objects.get(pkimage_hash)。照片被视为缺失的判定条件filesNone没有任何关联文件或main_fileNone没有主文件引用查询缺失照片的标准写法missing_photos Photo.objects.filter( Q(owneruser) (Q(filesNone) | Q(main_fileNone)) )这里的括号是承重设计。Python 中优先级高于|如果不加括号表达式会被解析为(Q(owneruser) Q(filesNone)) | Q(main_fileNone)导致main_fileNone这个分支完全不受owneruser限定会匹配到所有用户的照片——这是一次跨用户的数据泄露。该 bug 曾在 apps/backend/api/stats.py 中真实存在过修复时还配套了跨用户回归测试见下文测试验证。检测机制_check_files()核心实现Photo._check_files()是缺失检测的核心方法实现在 apps/backend/api/models/photo.pydef _check_files(self): for file in self.files.all(): if not file.path or not os.path.exists(file.path): self.files.remove(file) # 从照片的文件列表中移除 file.missing True # 将文件标记为缺失 file.save() self.save()该方法的工作流程遍历照片关联的所有文件检查文件路径是否为空、或磁盘上是否仍存在若文件消失则将其从photo.files中移除并把file.missing置为True保存保存照片本身的变更。一个值得注意的细节它把消失的文件从photo.files中移除但刻意让main_file继续指向该文件——这对后续的重关联Relinking至关重要具体原因见下文哈希重关联一节。调用时机_check_files()唯一的生产环境调用点是scan_missing_photos任务apps/backend/api/directory_watcher/scan_jobs.py。与之相反清除missing标记文件重新出现时不在这里处理而是由File.create独立完成见上文代码。后台任务两个核心 JobJob 类型定义缺失照片相关的两个 Job 类型定义在 apps/backend/api/models/long_running_job.pyJOB_SCAN_PHOTOS 1Scan PhotosJOB_DELETE_MISSING_PHOTOS 5Delete Missing PhotosJOB_SCAN_MISSING_PHOTOS 14Scan Missing PhotosJob 一扫描缺失照片scan_missing_photos类型JOB_SCAN_MISSING_PHOTOSJob 类型 14函数apps/backend/api/directory_watcher/scan_jobs.py 中的scan_missing_photos(user, job_id)目的检查某用户拥有的全部照片找出文件已消失的记录def scan_missing_photos(user, job_id: UUID): lrj LongRunningJob.get_or_create_job( useruser, job_typeLongRunningJob.JOB_SCAN_MISSING_PHOTOS, job_idjob_id, ) try: existing_photos Photo.objects.filter(owneruser.id).order_by(image_hash) paginator Paginator(existing_photos, 5000) lrj.update_progress(current0, targetpaginator.num_pages) for page in range(1, paginator.num_pages 1): # 允许任务在翻页间隙被 UI 取消 if is_job_cancelled(job_id): util.logger.info(Scan missing photos job cancelled) return for existing_photo in paginator.page(page).object_list: existing_photo._check_files() update_scan_counter(job_id) except Exception as e: util.logger.exception(An error occurred: ) lrj.fail(errore)关键特性分批处理每批 5000 张照片Paginator(existing_photos, 5000)控制内存占用任务复用通过LongRunningJob.get_or_create_job()获取 Job已排队的 Job 行会被复用并重启而不是创建重复任务job_id唯一进度上报lrj.update_progress(current, target)持久化计数供 UI 展示进度可取消每页之间轮询is_job_cancelled(job_id)定义于 apps/backend/api/directory_watcher/utils.py任务运行中可从 UI 取消异常处理任何异常都会通过lrj.fail(errore)将 Job 标记为失败自动触发在完整扫描完成后自动运行——见 apps/backend/api/directory_watcher/scan_jobs.py 的_queue_followup_jobs# 只有全量扫描、或扫描的是默认扫描目录且未限定具体文件时才需要补一轮缺失照片扫描 if full_scan or (scan_directory user.scan_directory and not scan_files): AsyncTask(scan_missing_photos, user, uuid.uuid4()).run()Job 二删除缺失照片delete_missing_photos类型JOB_DELETE_MISSING_PHOTOSJob 类型 5函数apps/backend/api/autoalbum.py 中的delete_missing_photos(user, job_id)目的从数据库中永久移除缺失照片及其关联数据def delete_missing_photos(user, job_id): lrj LongRunningJob.get_or_create_job( useruser, job_typeLongRunningJob.JOB_DELETE_MISSING_PHOTOS, job_idjob_id, ) try: missing_pks list( Photo.objects.filter( Q(owneruser) (Q(filesNone) | Q(main_fileNone)) ).values_list(pk, flatTrue) ) target len(missing_pks) lrj.update_progress(current0, targettarget) # 分批删除以限制峰值内存。相册中间表和 Face 记录由 # Photo.delete() 的数据库级联自动清理只有 AlbumThing 需要 # 事后刷新 photo_count / 封面图因为级联绕过了它的 # m2m_changed 接收器。 affected_album_thing_ids: set[int] set() for start in range(0, target, _DELETE_MISSING_BATCH_SIZE): batch_pks missing_pks[start : start _DELETE_MISSING_BATCH_SIZE] batch_qs Photo.objects.filter(pk__inbatch_pks) affected_album_thing_ids.update( AlbumThing.objects.filter(photos__inbatch_qs).values_list( id, flatTrue ) ) batch_qs.delete() lrj.update_progress(currentstart len(batch_pks), targettarget) for album_thing in AlbumThing.objects.filter(id__inaffected_album_thing_ids): album_thing.photo_count album_thing.photos.filter(hiddenFalse).count() album_thing.save(update_fields[photo_count]) album_thing.update_default_cover_photo() # File.hash 是 md5 str(user.id)所以该用户的缺失文件 # 通过该后缀来识别。 File.objects.filter( Q(hash__endswithstr(user.id)) Q(missingTrue) ).delete() lrj.complete() except Exception as e: logger.exception(An error occurred) lrj.fail(errore)实现要点缺失照片查询带括号保证两个 OR 分支都限定在请求用户范围内照片按_DELETE_MISSING_BATCH_SIZE值为 200定义于 apps/backend/api/autoalbum.py分批删除限制峰值内存文件清理过滤条件用str(user.id)——因为File.hash是md5 str(user.id)。如果传入User实例会字符串化为用户名导致匹配不到任何记录。从源码看该实现还同步维护了Tag.photos.through的计数refresh_tag_photo_counts(affected_tag_ids)比文档描述的略多一步什么会被删除数据库中的 Photo 记录标记为 missing 的 File 记录与日期相册、事物相册、地点相册、用户自建相册的关联照片关联的人脸检测记录。相册关联和人脸记录由Photo.delete()的数据库级联自动清理Face.photo是on_deleteCASCADE无需逐张照片显式循环。唯一例外是AlbumThing它的photo_count和封面图由m2m_changed接收器维护而级联会绕过该信号因此受影响相册的 ID 每批快照一次、删除后统一刷新不会被删除TODO磁盘上的缩略图文件详见已知问题与 TODO。API 端点删除缺失照片接口端点POST /api/deletemissingphotos实现DeleteMissingPhotosView位于 apps/backend/api/views/views.pyclass DeleteMissingPhotosView(APIView): def post(self, request, formatNone): return self._delete_missing_photos(request, format) extend_schema( deprecatedTrue, descriptionUse POST method instead, ) def get(self, request, formatNone): return self._delete_missing_photos(request, format) def _delete_missing_photos(self, request, formatNone): try: job_id uuid.uuid4() AsyncTask(delete_missing_photos, request.user, job_id).run() return Response({status: True, job_id: job_id}) except BaseException: logger.exception(An Error occurred) return Response({status: False})响应示例{ status: true, job_id: 550e8400-e29b-41d4-a716-446655440000 }删除操作作为后台 django-q2 任务AsyncTask(...).run()执行因此接口在任务入队时即返回 200而不是等清理完成。调用方应通过返回的job_id在对应的LongRunningJob上跟踪进度并轮询而不是把响应当作完成信号。注意该视图也兼容 GET已标记弃用以保持向后兼容两个方法都委托给同一个_delete_missing_photos辅助函数。照片统计接口缺失照片数量包含在用户统计 API 的响应中。计算逻辑在 apps/backend/api/stats.py 的get_count_stats(user)num_missing_photos Photo.objects.filter( Q(owneruser) Q(filesNone) | Q(main_fileNone) ).count()统计响应中包含{ num_photos: 1234, num_missing_photos: 5, // ... 其他统计 }注意该计数使用的就是与delete_missing_photos相同的带括号、按用户限定的过滤条件——Q(owneruser) (Q(filesNone) | Q(main_fileNone))。括号至关重要去掉括号后Python 的优先于|会将其解析为(Q(owneruser) Q(filesNone)) | Q(main_fileNone)第二个分支失去限定把其他用户main_fileNone的照片也统计进来。这正是 apps/backend/api/stats.py 中曾真实出现过的 bug修复时配套了跨用户回归测试apps/backend/api/tests/stats/test_count_stats_missing_photos.py。遗留的一个小问题该计数没有.distinct()而files是反向多对多关系因此一张照片关联多个File行时会被重复计数。delete_missing_photos不受影响因为它删除的是对象而非统计行数。基于哈希的重关联Relinking工作原理当文件重新出现在扫描目录中时LibrePhotos 会将其重新连接回原有的照片元数据而不是创建重复照片。一个File由内容哈希识别File.hash是md5(content) str(user.id)也是主键同时带有唯一的path因此重新出现的文件即使被重命名或移动只要仍在扫描目录内也能匹配回之前的记录。哈希计算calculate_hash(user, path)位于 apps/backend/api/models/file.pydef calculate_hash(user, path): hash_md5 hashlib.md5() with open(path, rb) as f: for chunk in iter(lambda: f.read(BUFFER_SIZE), b): hash_md5.update(chunk) return hash_md5.hexdigest() str(user.id) # ... 错误处理关键点哈希 文件内容 MD5 用户 ID用户 ID 保证多用户场景下照片按用户隔离缓冲区大小为 65536 字节BUFFER_SIZE定义于 apps/backend/api/models/file.py兼顾性能与内存。重关联流程扫描期间的重关联由 apps/backend/api/directory_watcher/ 两阶段扫描中两个相互独立的环节驱动而非靠Photo.image_hash查找File.createapps/backend/api/models/file.py按唯一path查记录。如果已有记录标记为missing而文件已回到磁盘就清除该标记existing File.objects.filter(pathpath).first() if existing: if existing.missing and os.path.exists(path): existing.missing False existing.save(update_fields[missing]) return existinggroup_files_into_photoapps/backend/api/directory_watcher/file_handlers.py重新接纳原有的Photo而不是新建existing_photo Photo.objects.filter( Q(owneruser) (Q(files__infiles) | Q(main_file__infiles)) ).first()命中后尚未挂接的文件会追加到该照片上且当更高优先级的变体出现时按FILE_TYPE_PRIORITY判断main_file会被升级。只有找不到匹配时才创建新的Photo。Q(main_file__infiles)这个分支是承重设计。回顾_check_files()的行为它把缺失文件从photo.files中摘除但让main_file仍指向该文件——于是重关联时按main_file匹配就能阻止一个重新出现的文件以相同image_hash生成重复的Photo。注意create_new_imageapps/backend/api/directory_watcher/file_handlers.py是遗留的单文件路径仅用于上传和 XMP 侧车文件分支。它不做image_hash查找、不做removed/in_trashcan恢复、也不调用_check_files()——上述重关联逻辑已经不在它里面了。该行为由 apps/backend/api/tests/scanning/test_missing_file_reappearance.py 覆盖验证。前端集成照片序列化器文件apps/backend/api/serializers/photos.pyget_image_path构建照片的文件路径列表def get_image_path(self, obj) - list[str]: try: paths [] for file in obj.files.all(): paths.append(file.path) return paths except Exception: return [Missing]Photo.files是ManyToManyField(File)而_check_files()会把消失的文件从该关系中移除。因此所有文件都消失的照片其image_path序列化结果为空列表[]。[Missing]只是意外异常时的错误兜底值并不是常规的缺失信号。前端通过空数组判断缺失场景VersionComponent.tsx和routes/_protected/photo.$id.tsx以photoDetail.image_path photoDetail.image_path.length 0做守卫MediaDisplay.tsx以!photoDetails?.image_path || !Array.isArray(photoDetails.image_path)做守卫。视频错误处理文件apps/frontend/src/components/lightbox/VideoPlayer.tsxMediaDisplay构建媒体 URL视频为/media/photos/hash.mp4嵌入媒体为/media/embedded_media/hash并交给VideoPlayer。当后端无法提供文件时video元素的onError处理器会设置错误状态VideoPlayer在仍可用的缩略图海报上方渲染红色告警并带 Retry 按钮if (error) { return ( div style{{ backgroundImage: posterUrl ? url(${posterUrl}) : undefined, /* ... */ }} Alert colorred titleVideo Unavailable Text sizesmThe video file could not be loaded. It may be missing, unsupported, or unavailable./Text Button variantlight colorred sizexs mtsm onClick{handleRetry}Retry/Button /Alert /div ); }设置界面用户删除缺失照片的入口在 Library 设置页apps/frontend/src/components/settings/Library.tsx通过/library路由访问仅当countStats.num_missing_photos 0时来自useFetchCountStatsQuery即上文照片统计中同一个num_missing_photos指标才渲染红色徽标。徽标文案来自settings.missingphotos翻译键Missing Photos位于 MantineHoverCard内其下拉内容settings.missingphotosdescription解释该功能用途点击徽标打开标题为settings.missingphotosbuttonRemove missing photos的Modal含 Cancel / Confirm 按钮。Confirm 调用deleteMissingPhotos.mutate()后关闭弹窗该变更操作位于apps/frontend/src/api_client/photos/hooks/useDeleteMissingPhotosMutation.ts。它向/deletemissingphotos发起 POST/api前缀来自 fetch client 的 base URL用 zod 校验{ status, job_id }响应成功后使 auto-albums、date-albums、recently-added、count-stats、photo-month-count 等查询键失效——了解删除后哪些缓存视图会过期对排查问题很有帮助同一操作还暴露在 Spotlight 命令面板中apps/frontend/src/components/spotlight/useSpotlightActions.tsaction id 为action-delete-missing标签键spotlight.actions.deleteMissing。它直接触发同一个 mutation没有确认弹窗且在 worker 队列无法接收任务时禁用。英文字符串settings.missingphotos、settings.missingphotosbutton、settings.missingphotosdescription位于apps/frontend/src/locales/en/translation.json其他语言由 Weblate 维护。未来实现方向实时文件系统监控目标通过主动的文件追踪消除大多数缺失照片场景。规划中的功能文件系统监视器实现 inotifyLinux、FSEventsmacOS或 watchdog 库实时监控扫描目录的文件变化即时触发更新而不是等待手动扫描移动/重命名检测检测扫描目录内文件的移动自动更新数据库中的文件路径保留全部元数据、评分和关联即时重关联文件出现时立即做基于哈希的匹配无需手动扫描大幅缩短缺失照片的窗口期收益文件变化时 UI 近乎实时更新减少数据库查询无需周期性扫描缺失照片更少用户体验更好系统资源占用更低。实现考量持续监控的性能开销高效处理大型目录树网络存储兼容性NAS、SMB、NFSDocker 容器文件系统事件传播监控不可用时的优雅降级。已知问题与 TODO当前 TODO删除缩略图delete_missing_photos中未实现Thumbnail行会随Photo级联删除Thumbnail.photo是OneToOneField(..., on_deleteCASCADE, primary_keyTrue)但磁盘上的文件会遗留——Thumbnail没有post_delete接收器孤立文件残留在MEDIA_ROOT$BASE_DATA/protected_media/下以image_hash命名分布在thumbnails_big/.webpsquare_thumbnails/.webp另有视频预览用的.mp4square_thumbnails_small/.webp另有视频预览用的.mp4应清理以释放磁盘空间。移动 delete_missing_photos 函数autoalbum.py该函数带有# To-Do: This does not belong here注释应移动到更合适的模块如photo_operations.py或类似位置。边界情况符号链接部分场景下可能处理不正确网络存储超时慢速网络存储可能导致误报权限变化权限变更可能让文件显得缺失竞态条件扫描期间文件变化可能造成数据不一致。代码组织与测试关键文件清单模型层apps/backend/api/models/file.py —File模型missing标记、唯一path、calculate_hashapps/backend/api/models/photo.py —Photo模型与_check_files()方法apps/backend/api/models/long_running_job.py — Job 类型定义apps/backend/api/models/thumbnail.py — 缩略图记录随照片级联删除文件不删除业务逻辑apps/backend/api/directory_watcher/ — 扫描与重关联实现两阶段扫描的包scan_jobs.py—scan_photos、scan_missing_photosfile_handlers.py— 阶段一create_file_record、阶段二group_files_into_photo、遗留create_new_imagefile_grouping.py— 分组键、select_main_file、FILE_TYPE_PRIORITYprocessing_jobs.py、repair_jobs.py、utils.py__init__.py重新导出公共名称使现有的from api.directory_watcher import ...导入继续可用apps/backend/api/autoalbum.py —delete_missing_photos函数API 视图apps/backend/api/views/views.py — 删除缺失照片端点apps/backend/api/views/photos.py — 照片操作统计apps/backend/api/stats.py — 包含缺失照片在内的计数计算序列化器apps/backend/api/serializers/photos.py — 照片序列化get_image_path前端apps/frontend/src/components/settings/Library.tsx— 缺失照片徽标与确认弹窗apps/frontend/src/api_client/photos/hooks/useDeleteMissingPhotosMutation.ts—POST /deletemissingphotos与缓存失效apps/frontend/src/components/spotlight/useSpotlightActions.ts— Spotlight Delete Missing Photos 操作apps/frontend/src/components/lightbox/VideoPlayer.tsx— 视频加载错误 UI测试验证手动测试缺失照片功能时遵循以下步骤准备创建带有效文件的照片触发在 LibrePhotos 之外移除文件系统上的文件扫描运行scan_missing_photos任务验证检查照片被标记为缺失恢复把文件放回去并重新扫描验证重关联确认照片自动重关联删除用delete_missing_photos测试永久删除。仓库中的自动化测试覆盖了上述全部行为修改相关代码时应扩展这些测试apps/backend/api/tests/photos/test_delete_missing_photos.py — 覆盖delete_missing_photos清理流程Q(owneruser) (Q(filesNone) | Q(main_fileNone))过滤条件的按用户隔离、hash__endswithstr(user.id)缺失文件过滤、LongRunningJob进度上报、分批处理、相册中间表的级联清理、AlbumThing.photo_count与封面图的单次刷新、视图上的AsyncTask包装以及 Tag 计数刷新apps/backend/api/tests/scanning/test_missing_file_reappearance.py — 覆盖文件回到磁盘时清除File.missing标记test_clears_missing_flag_when_file_is_back_on_disk、test_keeps_missing_flag_when_file_is_still_gone以及重出现的文件被原Photo重新接纳不产生重复Phototest_reappearing_file_is_readopted_by_original_photoapps/backend/api/tests/stats/test_count_stats_missing_photos.py — 覆盖统计接口中缺失照片计数的跨用户隔离回归。在 backend 应用目录下运行通常在backend容器内参见 开发环境安装cd apps/backend python manage.py test api.tests.photos.test_delete_missing_photos api.tests.scanning.test_missing_file_reappearance api.tests.stats.test_count_stats_missing_photos相关文档缺失照片用户指南 — 面向用户的识别与解决缺失照片的指南照片列表实现 — 照片查询与展示缩略图 — 缩略图的生成与存储上传系统 — 上传时的文件处理【免费下载链接】librephotosA self-hosted open source photo management service.项目地址: https://gitcode.com/GitHub_Trending/li/librephotos创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
上一篇/下一篇内容由系统自动关联 返回资讯列表 →