详解 LibrePhotos Web 上传:分块上传、去重跳过与上传后后台处理链路
2026/9/16 14:32:06 网站建设 项目流程

详解 LibrePhotos Web 上传:分块上传、去重跳过与上传后后台处理链路

【免费下载链接】librephotosA self-hosted open source photo management service.项目地址: https://gitcode.com/GitHub_Trending/li/librephotos

本文围绕 LibrePhotos 用户上传(Upload)功能展开:先讲清楚网页端上传按钮的使用方式与文件类型限制,再剖析“1MB 分块上传 + md5 去重 + 扫描目录落盘”的完整工作流程,最后覆盖Allow uploads开关、allowUpload/ALLOW_UPLOAD环境变量、每用户扫描目录的配置方法,以及各类上传故障的排查手段。读完本文,你可以独立完成 Compose 部署下的上传功能启用,并理解上传请求从前端切片到后端入库、触发后台任务链的全部调用关系。

一、如何使用上传功能

在 LibrePhotos 网页界面右上角有一个上传按钮。点击它会打开文件选择器,可以多选文件一次性上传。前端该按钮由 ChunkedUploadButton 组件实现,它的可见性与可用性受两个条件共同控制:

  1. 全局开关:组件会读取站点设置中的allow_upload,若为假则直接不渲染上传按钮(if (!settings?.allow_upload) return null)。该设置对应管理后台的Allow uploads开关,在后端由 site_settings 模式 中的allow_upload布尔字段描述,并经 views.py 的 settings 接口读写。
  2. 每用户扫描目录:组件从userSelfDetails.scan_directory判断当前账号是否配置了扫描目录。未配置时按钮保持可见但置灰,鼠标悬停显示提示 “Scan directory not configured - contact administrator”,且拖放/选择文件不会发起上传。

支持哪些文件类型?

官方文档说明:LibrePhotos 接受所有 MIME 类型为 image 或 video 的文件。这与前后端两处实现一致:

  • 前端 ChunkedUploadButton 使用 react-dropzone 的accept限制为image/*video/*,因此文件选择器只会列出图片与视频;
  • 后端在完成上传时调用is_valid_media(uploaded_file.file.path, user)做最终校验,非法类型会删除已接收的临时分块文件并返回 HTTP 400 “File type not allowed”(见 upload.py 的on_completion方法)。

二、上传是如何工作的:分块上传与去重机制

官方文档对上传流程的概括是:先按“hash = md5 + user_id”检查文件是否已存在于服务器上——已存在则跳过,不存在则上传;文件按 1MB 分块发送;每个文件上传完成后,后端注册这张照片并为它排入一条后台任务链(元数据与缩略图、字幕、地理定位、相册日期、人脸提取),不会触发目录扫描。下面结合源码逐点验证。

2.1 前端:1MB 分块与 MD5 计算

前端分块逻辑位于 chunkedUpload.ts:

// < 1MB chunks, because of the nginx default client_max_body_size export const CHUNK_SIZE = 1000000;

注意两个细节:

  • 块大小取1000000字节(略小于 1 MiB),源码注释明确说明这是为了兼容 nginx 默认的client_max_body_size(1MB)限制,避免单块被代理层拒绝;
  • calculateChunks()file.slice把文件切成若干BlobcalculateMD5()则以 25 MiB 的步长分片读取文件并增量计算 MD5,避免一次性把大文件读入内存。

上传队列 useUploadQueue.ts 逐块调用上传 mutation,useUploadMutation.ts 在请求头中携带Content-Range: bytes <offset>-<end>/<total>,告诉服务端当前分块在文件中的位置。

2.2 后端:分块接收、续传校验与 MD5 校验

后端分块接收由 Django 视图 ChunkedUploadView 实现,LibrePhotos 在 upload.py 中将其子类化为UploadPhotosChunked。关键机制:

  • 定位分块位置content_range_pattern解析bytes start-end/total格式的Content-Range头;若客户端未提供该头,则按“整块即整个文件”处理(与 jquery.file.upload 的行为保持一致);
  • 顺序与大小校验check_chunk()会校验三件事——总大小不超过CHUNKED_UPLOAD_MAX_BYTES(settings.py 中默认None即不限制)、分块起始偏移必须等于已接收字节数(chunked_upload.offset != start时返回 400 “Offsets do not match”)、分块实际大小与头声明一致;
  • 可续传:每次成功接收分块后,响应中返回upload_idoffsetexpires。客户端凭upload_id可从中断处继续上传;分块在服务器上按 settings.py 的DEFAULT_UPLOAD_PATHchunked_uploads/%Y/%m/%d)暂存为.part文件,默认 1 天后过期(EXPIRATION_DELTA一天),过期后服务端返回 410 “Upload has expired”;
  • 完成请求必须带 MD5:ChunkedUploadCompleteView 要求提交upload_id与整个文件的md5,服务端会比对已落盘内容的 MD5 与客户端声明值,不一致返回 400 “md5 checksum does not match”。这就是文档所说“按 hash 校验”的服务端落点。

每次分块请求还会经过check_permissions,它先检查站点级ALLOW_UPLOAD开关(关闭时返回 403 “Uploading is not allowed”),再通过authenticate_upload_request()解析 Cookie 中的 JWT 并定位用户——upload.py 中的该函数只接受 Cookie 内的jwt,凭证缺失或无效都返回 403。

2.3 去重:为什么同一张照片只存一次

分块合并完成后,UploadPhotosChunkedComplete.on_completion(upload.py)执行如下步骤:

  1. 重新鉴权,并调用validate_scan_directory(user):未配置扫描目录、或目录在服务器上不存在时,直接抛出 400 错误(错误文案见下文第三节);
  2. is_valid_media()校验文件类型;
  3. get_valid_filename()净化文件名,并把设备来源固定为device = "web"
  4. 计算整个文件的image_hashcalculate_hash_b64(user, ...),以用户为参数参与哈希,对应文档中“md5 + user_id”的表述);
  5. 调用target_path()决定落盘位置(见 2.4);
  6. 删除临时分块文件,若判定为重复则直接返回 200 与{"detail": "Photo duplicated. No new import performed."},不再写入磁盘、也不再入库。

target_path()的去重判定共有三层(upload.py):

  • 数据库中已存在Photo.image_hash == image_hash的记录 → 返回空路径,视为重复;
  • 目标文件{scan_directory}/uploads/web/{filename}在磁盘上已存在,且其重新计算的哈希与新文件相同 → 同样视为重复;
  • 文件已存在但哈希不同 → 改为写入文件名_<image_hash>.扩展名的新路径,避免覆盖不同的文件。

2.4 落盘位置:{scan_directory}/uploads/web/{filename}

target_path()upload_dir = os.path.join(user.scan_directory, "uploads", device)(device 恒为web),即上传文件最终保存在{scan_directory}/uploads/web/{filename}。若该目录尚未存在,on_completion会依次创建uploadsuploads/web两级目录后再写入。

在 Docker Compose 部署中,扫描目录由宿主机目录绑定挂载进容器:docker-compose.yml 中 backend 服务有- ${scanDirectory}:/data一行,示例 librephotos.env 中scanDirectory=./librephotos/pictures。因此只要scanDirectory指向宿主机上真实存在的持久化路径,上传文件就写在挂载卷内,容器重建不会丢失——这正是文档所说“文件存放在挂载的主机目录中,是持久的”。

三、扫描目录:上传行为的前置条件

上传行为完全取决于该用户是否配置了扫描目录,源码中的校验函数validate_scan_directory()(upload.py)只有一段逻辑:

def validate_scan_directory(user): if not user.scan_directory or user.scan_directory.strip() == "": raise _bad_request( "Upload failed: No scan directory configured. ..." ) if not os.path.exists(user.scan_directory): raise _bad_request( f"Upload failed: Scan directory '{user.scan_directory}' does not exist. ..." )

由此得到文档中的两种行为:

场景表现
扫描目录已正确配置文件保存到{scan_directory}/uploads/web/{filename},这是正常且预期的行为,文件落在挂载的主机目录中、具有持久性
账号未配置扫描目录前端上传按钮置灰并显示 “Scan directory not configured - contact administrator”;即使请求直接打到后端,on_completion阶段也返回 HTTP 400 “Upload failed: No scan directory configured…” ,不写任何文件
已配置但路径在服务器上不存在按钮保持可用,但最终分块提交时被拒绝,返回 HTTP 400 “Upload failed: Scan directory '' does not exist…”,不写任何文件

如何为每个用户配置扫描目录

  1. 仅管理员可操作:只有管理员能为用户设置扫描目录;
  2. 进入管理后台:点击右上角头像 →Admin Area
  3. 设置 Scan Directory:为每个用户手动填写Scan Directory
  4. 验证路径:目录必须存在,并且容器能访问到它(在 Compose 部署中即位于/data数据根之内)。

四、启用 / 停用上传功能:Allow uploads 开关与环境变量的优先级

上传功能有两条前置配置,缺一不可:

  1. Upload feature enabled:在管理后台打开Allow uploads。Docker Compose 用户可以在首次启动前.env中加入allowUpload=true来预启用——Compose 会将其传给后端环境变量ALLOW_UPLOAD。注意.env里的变量名是allowUpload,而 Compose 注入后端时才是ALLOW_UPLOAD(见 docker-compose.yml 中- ALLOW_UPLOAD=${allowUpload:-false});
  2. Scan directory configured:每个用户都必须由管理员配置好扫描目录。

关于优先级,文档与源码结论一致:管理后台的开关是权威设置,环境变量只供给初始默认值。后端在 production.py 中注册 Constance 配置:

"ALLOW_UPLOAD": ( os.environ.get("ALLOW_UPLOAD", "True") not in ("false", "False", "0", "f"), ..., )

即环境变量仅作为 Constance 站点设置的默认值。一旦Allow uploads开关(或首次运行设置向导)把值保存进了数据库,存储的设置优先,之后再修改ALLOW_UPLOAD环境变量不再生效。管理端通过 views.py 的 settings 接口site_config.ALLOW_UPLOAD = request.data["allow_upload"]写库,读取时同样返回site_config.ALLOW_UPLOAD

五、上传之后的处理链路

文档指出,上传完成后处理已上传照片有两条路径:

  1. 自动处理:上传流程会自动为上传的照片触发处理。对应 upload.py 中import_photo()方法:先create_new_image(user, photo_path)把照片注册入库,然后构建并运行一条 django-q 任务链:

    chain = Chain() photo = create_new_image(user, photo_path) chain.append(handle_new_image, user, photo_path, image_hash, photo) # 元数据 + 缩略图 chain.append(generate_captions_wrapper, photo, True) # 字幕 chain.append(photo._geolocate) # 地理定位 chain.append(photo._add_location_to_album_dates) # 按日期/地点归档 chain.append(photo._extract_faces) # 人脸提取 chain.run()

    该链路不会触发目录扫描(directory scan)——上传路径独立于 directory_watcher 的扫描作业,只对刚上传的这一张照片工作;

  2. 手动扫描:前往 Library 页面点击扫描按钮,可以手动扫描所有照片(包括未处理的存量文件)。

相关行为可通过 apps/backend/api/tests/uploads/ 下的测试验证,例如 test_chunked_upload_permissions.py 覆盖ALLOW_UPLOAD开/关时的 403 分支,test_chunked_upload_completion.py 覆盖完成阶段的鉴权与校验顺序。

六、故障排查(Troubleshooting)

上传按钮置灰

  • 原因:该账号未配置扫描目录。悬停按钮显示 “Scan directory not configured - contact administrator”。前端 ChunkedUploadButton 在scan_directory为空时把 dropzone 设为disabled并包裹 Tooltip。
  • 解决:请管理员按上文“如何为每个用户配置扫描目录”一节设置扫描目录。

上传报 “Scan directory does not exist”

  • 原因:配置的扫描目录路径在后端容器内不存在。后端validate_scan_directory()通过os.path.exists(user.scan_directory)判定,路径在容器视角下缺失即触发 400。
  • 解决:检查 Compose 文件中${scanDirectory}:/data绑定挂载行,并确认宿主机上该目录真实存在、且扫描目录值指向数据根(默认/data)内的子路径。

容器重启后上传的文件消失

  • 原因:数据根没有宿主机目录支撑。扫描目录必须位于后端数据根(默认/data)之内——放在之外的路径会被 “Scan directory must be inside the data root.” 拒绝。此时上传本身成功,但若/data没有绑定挂载,写入内容只存在于容器文件系统,容器重建即丢失。
  • 解决:确认 Compose 文件的backend服务挂载了宿主机目录到/data(即${scanDirectory}:/data行),且.envscanDirectory指向宿主机上真实、持久化的路径。

上传报权限错误

  • 原因:容器对扫描目录没有写权限。
  • 解决:检查目录权限,确保容器进程能向挂载目录写入。

上传按钮不可见

  • 原因:上传功能被整体停用。前端组件在settings.allow_upload为假时直接返回null
  • 解决:在管理后台打开Allow uploads。再次强调:allowUpload环境变量只提供默认值;一旦该设置从管理后台或首次运行向导被保存进数据库,修改环境变量就没有效果,必须以数据库中的值为准。

小结

LibrePhotos 的 Web 上传是一条“前端 1MB 分块 → 服务端顺序/MD5 双重校验 → 按用户哈希去重 → 落盘到{scan_directory}/uploads/web/→ django-q 任务链自动处理”的完整链路,其可用性由站点级Allow uploads开关(数据库值优先于ALLOW_UPLOAD环境变量)与每用户扫描目录共同决定。排障时抓住两个核心事实即可定位绝大多数问题:扫描目录必须真实存在于容器内的数据根下,且文件持久性依赖 Compose 对/data的绑定挂载。

【免费下载链接】librephotosA self-hosted open source photo management service.项目地址: https://gitcode.com/GitHub_Trending/li/librephotos

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询