diff --git a/app/modules/filemanager/storages/alist.py b/app/modules/filemanager/storages/alist.py index 83c503ef..504f68b7 100644 --- a/app/modules/filemanager/storages/alist.py +++ b/app/modules/filemanager/storages/alist.py @@ -1,3 +1,4 @@ +import hashlib import json import time from datetime import datetime @@ -708,6 +709,7 @@ class Alist(StorageBase, metaclass=WeakSingleton): # 获取文件大小 target_name = new_name or path.name target_path = Path(fileitem.path) / target_name + stat = path.stat() # 初始化进度回调 progress_callback = transfer_process(path.as_posix()) @@ -718,6 +720,9 @@ class Alist(StorageBase, metaclass=WeakSingleton): headers.setdefault("Content-Type", "application/octet-stream") headers.setdefault("As-Task", str(task).lower()) headers.setdefault("File-Path", encoded_path) + headers.setdefault("Content-Length", str(stat.st_size)) + headers.setdefault("Last-Modified", str(int(stat.st_mtime * 1000))) + headers.update(self.__get_upload_hash_headers(path)) # 创建自定义的文件流,支持进度回调 class ProgressFileReader: @@ -783,6 +788,28 @@ class Alist(StorageBase, metaclass=WeakSingleton): logger.error(f"【OpenList】上传文件 {path} 失败:{e}") return None + @staticmethod + def __get_upload_hash_headers(path: Path) -> dict: + """ + 计算 OpenList 秒传所需的文件哈希请求头。 + """ + md5_hash = hashlib.md5() + sha1_hash = hashlib.sha1() + sha256_hash = hashlib.sha256() + with open(path, "rb") as file_handler: + while True: + chunk = file_handler.read(1024 * 1024) + if not chunk: + break + md5_hash.update(chunk) + sha1_hash.update(chunk) + sha256_hash.update(chunk) + return { + "X-File-Md5": md5_hash.hexdigest(), + "X-File-Sha1": sha1_hash.hexdigest(), + "X-File-Sha256": sha256_hash.hexdigest(), + } + def detail(self, fileitem: schemas.FileItem) -> Optional[schemas.FileItem]: """ 获取文件详情 diff --git a/tests/test_alist_storage.py b/tests/test_alist_storage.py index ceb574ad..1ad377d8 100644 --- a/tests/test_alist_storage.py +++ b/tests/test_alist_storage.py @@ -1,5 +1,7 @@ +import hashlib import unittest from pathlib import Path +from tempfile import TemporaryDirectory from unittest.mock import MagicMock, patch from app.modules.filemanager.storages import alist as alist_module @@ -137,3 +139,47 @@ class AlistStorageTest(unittest.TestCase): self.assertEqual("alist", target.storage) self.assertEqual("file", target.type) self.assertEqual(1024, target.size) + + def test_upload_sends_hash_headers_for_rapid_upload(self): + """ + OpenList 上传应附带文件哈希头,供服务端尝试秒传。 + """ + with TemporaryDirectory() as temp_dir: + local_path = Path(temp_dir) / "rapid.bin" + content = b"moviepilot-openlist-rapid-upload" + local_path.write_bytes(content) + upload_dir = FileItem( + storage="alist", + type="dir", + path="/library/", + name="library", + basename="library", + ) + uploaded_item = FileItem( + storage="alist", + type="file", + path="/library/rapid.bin", + name="rapid.bin", + basename="rapid", + extension="bin", + size=len(content), + ) + request_utils = MagicMock() + request_utils.put_res.return_value = _FakeResponse( + {"code": 200, "message": "success", "data": None} + ) + + with patch.object(Alist, "get_conf", return_value={"url": "http://openlist.test", "token": "token"}): + with patch.object(alist_module, "RequestUtils", return_value=request_utils) as request_utils_factory: + with patch.object(self.storage, "_delay_get_item", return_value=uploaded_item): + result = self.storage.upload(upload_dir, local_path) + + self.assertEqual(uploaded_item, result) + request_utils.put_res.assert_called_once() + headers = request_utils_factory.call_args.kwargs["headers"] + self.assertEqual(hashlib.md5(content).hexdigest(), headers["X-File-Md5"]) + self.assertEqual(hashlib.sha1(content).hexdigest(), headers["X-File-Sha1"]) + self.assertEqual(hashlib.sha256(content).hexdigest(), headers["X-File-Sha256"]) + self.assertEqual(str(len(content)), headers["Content-Length"]) + self.assertEqual("application/octet-stream", headers["Content-Type"]) + self.assertEqual("/library/rapid.bin", headers["File-Path"])