656 lines
28 KiB
Python
656 lines
28 KiB
Python
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import shutil
|
|
import threading
|
|
import time
|
|
import uuid
|
|
from dataclasses import dataclass
|
|
from pathlib import Path, PurePosixPath
|
|
|
|
from .config import Settings
|
|
from .database import Database, utcnow
|
|
from .remote import AlistClient, openlist_endpoint_from_webdav_url, safe_relative_path
|
|
from .security import SecretStore
|
|
from .sources import SourceItem, SourceService, _fingerprint
|
|
|
|
|
|
def _remote_join(*parts: str) -> str:
|
|
cleaned: list[str] = []
|
|
for part in parts:
|
|
value = str(part or "").strip()
|
|
# PurePosixPath.parent renders a root parent as ".". It is a
|
|
# perfectly valid internal representation of the OpenList root, but
|
|
# must not be passed to safe_relative_path (where dot segments are
|
|
# deliberately rejected).
|
|
if not value or value == ".":
|
|
continue
|
|
cleaned.append(safe_relative_path(value))
|
|
return "/".join(value for value in cleaned if value)
|
|
|
|
|
|
def _remote_parent(path: str) -> str:
|
|
parent = str(PurePosixPath(safe_relative_path(path, allow_empty=False)).parent)
|
|
return "" if parent == "." else safe_relative_path(parent)
|
|
|
|
|
|
def _object_size(value: dict | None) -> int | None:
|
|
if value is None or bool(value.get("is_dir")):
|
|
return None
|
|
try:
|
|
return int(value.get("size") or 0)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
def normalize_task_state(value: object) -> int:
|
|
"""Normalize AList v3 string states and OpenList v4 integer states."""
|
|
|
|
names = {
|
|
"pending": 0,
|
|
"running": 1,
|
|
"succeeded": 2,
|
|
"finished": 2,
|
|
"canceling": 3,
|
|
"canceled": 4,
|
|
"cancelled": 4,
|
|
"errored": 5,
|
|
"failing": 6,
|
|
"failed": 7,
|
|
"waiting_retry": 8,
|
|
"before_retry": 9,
|
|
}
|
|
text = str(value if value is not None else "").strip().lower()
|
|
if text in names:
|
|
return names[text]
|
|
try:
|
|
return int(text)
|
|
except ValueError:
|
|
try:
|
|
return int(float(text))
|
|
except ValueError:
|
|
return -1
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class OpenListNativeConfig:
|
|
endpoint: str
|
|
username: str
|
|
password: str
|
|
verify_tls: bool
|
|
local_staging_path: Path
|
|
source_path: str
|
|
target_path: str
|
|
encrypted: bool
|
|
|
|
|
|
class OpenListNativeService:
|
|
"""Coordinate OpenList server-side copy without streaming media bytes."""
|
|
|
|
def __init__(
|
|
self,
|
|
db: Database,
|
|
settings: Settings,
|
|
sources: SourceService,
|
|
secrets: SecretStore,
|
|
):
|
|
self.db = db
|
|
self.settings = settings
|
|
self.sources = sources
|
|
self.secrets = secrets
|
|
self._clients_guard = threading.Lock()
|
|
self._clients: dict[str, tuple[str, AlistClient]] = {}
|
|
|
|
@staticmethod
|
|
def _endpoint_and_target(config: dict) -> tuple[str, str]:
|
|
endpoint = str(config.get("openlist_endpoint") or "").strip().rstrip("/")
|
|
target = str(config.get("openlist_target_path") or config.get("root_path") or "")
|
|
if not endpoint:
|
|
inferred = openlist_endpoint_from_webdav_url(str(config.get("base_url") or ""))
|
|
if inferred:
|
|
endpoint, inferred_target = inferred
|
|
target = target or inferred_target
|
|
else:
|
|
endpoint = str(config.get("base_url") or "").strip().rstrip("/")
|
|
return endpoint, safe_relative_path(target)
|
|
|
|
def configuration(self, source: dict, *, require_enabled: bool = True) -> OpenListNativeConfig:
|
|
config = source["config"]
|
|
if require_enabled and config.get("storage_backend") != "openlist_native":
|
|
raise ValueError("媒体库未启用 OpenList 原生后台复制")
|
|
endpoint, target_path = self._endpoint_and_target(config)
|
|
username = str(config.get("username") or "").strip()
|
|
password = str(source.get("secrets", {}).get("password") or "")
|
|
local_value = str(config.get("openlist_local_staging_path") or "").strip()
|
|
# An OpenList local storage can be mounted at its root. Empty here is
|
|
# therefore meaningful (the source file is addressed as
|
|
# ``/<upload-id>/...``), unlike a missing local staging directory.
|
|
source_path = safe_relative_path(str(config.get("openlist_source_path") or ""))
|
|
if not endpoint.startswith(("http://", "https://")):
|
|
raise ValueError("OpenList API 地址无效")
|
|
if not username or not password:
|
|
raise ValueError("OpenList 用户名或密码为空")
|
|
local_staging = Path(local_value)
|
|
if not local_value or not local_staging.is_absolute():
|
|
raise ValueError("ImageFind 本地中转目录必须是绝对路径")
|
|
if local_staging == Path("/") or local_staging.is_symlink():
|
|
raise ValueError("ImageFind 本地中转目录不能是根目录或符号链接")
|
|
return OpenListNativeConfig(
|
|
endpoint=endpoint,
|
|
username=username,
|
|
password=password,
|
|
verify_tls=bool(config.get("verify_tls", True)),
|
|
local_staging_path=local_staging,
|
|
source_path=source_path,
|
|
target_path=target_path,
|
|
encrypted=config.get("mode") == "encrypted",
|
|
)
|
|
|
|
def enabled(self, source_id: str) -> bool:
|
|
try:
|
|
return self.sources.get(source_id)["config"].get("storage_backend") == "openlist_native"
|
|
except KeyError:
|
|
return False
|
|
|
|
@staticmethod
|
|
def _fingerprint(config: OpenListNativeConfig) -> str:
|
|
value = "\0".join(
|
|
(
|
|
config.endpoint,
|
|
config.username,
|
|
hashlib.sha256(config.password.encode()).hexdigest(),
|
|
str(config.verify_tls),
|
|
)
|
|
)
|
|
return hashlib.sha256(value.encode()).hexdigest()
|
|
|
|
def client(self, source_id: str, source: dict | None = None) -> AlistClient:
|
|
source = source or self.sources.get(source_id)
|
|
config = self.configuration(source)
|
|
fingerprint = self._fingerprint(config)
|
|
with self._clients_guard:
|
|
cached = self._clients.get(source_id)
|
|
if cached and cached[0] == fingerprint:
|
|
return cached[1]
|
|
if cached:
|
|
cached[1].close()
|
|
client = AlistClient(
|
|
config.endpoint,
|
|
config.username,
|
|
config.password,
|
|
verify_tls=config.verify_tls,
|
|
timeout=self.settings.remote_timeout_seconds,
|
|
)
|
|
self._clients[source_id] = (fingerprint, client)
|
|
return client
|
|
|
|
def close(self) -> None:
|
|
with self._clients_guard:
|
|
clients = list(self._clients.values())
|
|
self._clients.clear()
|
|
for _, client in clients:
|
|
client.close()
|
|
|
|
def _candidate(self, source_id: str, values: dict) -> dict:
|
|
source = self.sources.get(source_id)
|
|
config = dict(source["config"])
|
|
secret = dict(source.get("secrets", {}))
|
|
inferred = openlist_endpoint_from_webdav_url(str(config.get("base_url") or ""))
|
|
endpoint = values.get("endpoint") or config.get("openlist_endpoint") or (inferred[0] if inferred else None)
|
|
target_path = values.get("target_path")
|
|
if target_path is None:
|
|
target_path = (
|
|
config.get("openlist_target_path")
|
|
or config.get("root_path")
|
|
or (inferred[1] if inferred else "")
|
|
)
|
|
config.update(
|
|
{
|
|
"openlist_endpoint": str(endpoint or "").rstrip("/"),
|
|
"openlist_local_staging_path": str(values.get("local_staging_path") or ""),
|
|
"openlist_source_path": str(values.get("source_path") or ""),
|
|
"openlist_target_path": safe_relative_path(str(target_path or "")),
|
|
"username": str(values.get("username") or config.get("username") or ""),
|
|
"verify_tls": bool(values.get("verify_tls", config.get("verify_tls", True))),
|
|
}
|
|
)
|
|
if values.get("password"):
|
|
secret["password"] = str(values["password"])
|
|
return {**source, "config": config, "secrets": secret}
|
|
|
|
def probe(self, source_id: str, values: dict) -> dict:
|
|
candidate = self._candidate(source_id, values)
|
|
config = self.configuration(candidate, require_enabled=False)
|
|
config.local_staging_path.mkdir(parents=True, exist_ok=True)
|
|
probe_id = uuid.uuid4().hex
|
|
local_directory = config.local_staging_path / f".imagefind-probe-{probe_id}"
|
|
local_file = local_directory / "probe.bin"
|
|
target_directory = _remote_join(config.target_path, f".imagefind-probe-{probe_id}")
|
|
target_file = _remote_join(target_directory, "probe.bin")
|
|
source_file = _remote_join(config.source_path, local_directory.name, "probe.bin")
|
|
payload = b"imagefind-openlist-native-copy-probe"
|
|
client = AlistClient(
|
|
config.endpoint,
|
|
config.username,
|
|
config.password,
|
|
verify_tls=config.verify_tls,
|
|
timeout=self.settings.remote_timeout_seconds,
|
|
)
|
|
task_id = ""
|
|
try:
|
|
client.probe()
|
|
local_directory.mkdir(mode=0o700)
|
|
local_file.write_bytes(payload)
|
|
visible = None
|
|
for delay in (0, 0.5, 1, 2, 4):
|
|
if delay:
|
|
time.sleep(delay)
|
|
visible = client.object_info(source_file)
|
|
if _object_size(visible) == len(payload):
|
|
break
|
|
if _object_size(visible) != len(payload):
|
|
raise RuntimeError("OpenList 无法看到 ImageFind 本地中转文件,请检查本地存储挂载映射")
|
|
client.ensure_directory(target_directory)
|
|
tasks = client.copy_file(source_file, target_file)
|
|
task_id = tasks[0] if tasks else ""
|
|
deadline = time.monotonic() + 120
|
|
finished = not task_id
|
|
while task_id and time.monotonic() < deadline:
|
|
task = client.copy_task_info(task_id)
|
|
if task is None:
|
|
break
|
|
state = normalize_task_state(task.get("state"))
|
|
if state == 2:
|
|
finished = True
|
|
break
|
|
if state in {4, 5, 6, 7}:
|
|
raise RuntimeError("OpenList 后台复制测试失败")
|
|
time.sleep(1)
|
|
if task_id and not finished:
|
|
raise RuntimeError("OpenList 后台复制测试超时")
|
|
result = client.object_info(target_file)
|
|
if _object_size(result) != len(payload):
|
|
raise RuntimeError("OpenList 后台复制完成后目标长度不一致")
|
|
return {
|
|
"ok": True,
|
|
"message": "OpenList 登录、本地挂载、后台复制和目标校验均正常",
|
|
"target_path": config.target_path,
|
|
}
|
|
finally:
|
|
if task_id:
|
|
try:
|
|
client.cancel_copy_task(task_id)
|
|
except Exception:
|
|
pass
|
|
try:
|
|
client.remove(target_directory)
|
|
except Exception:
|
|
pass
|
|
client.close()
|
|
shutil.rmtree(local_directory, ignore_errors=True)
|
|
|
|
def configure(self, source_id: str, values: dict) -> dict:
|
|
enabled = bool(values.get("enabled", True))
|
|
source = self.sources.get(source_id)
|
|
with self.db.read() as conn:
|
|
active = conn.execute(
|
|
"SELECT count(*) FROM uploads WHERE source_id=? "
|
|
"AND status IN ('receiving','queued','transferring','indexing')",
|
|
(source_id,),
|
|
).fetchone()[0]
|
|
if active:
|
|
raise ValueError("该媒体库仍有未完成上传,完成或取消后才能切换 OpenList 模式")
|
|
if not enabled:
|
|
config = dict(source["config"])
|
|
config["storage_backend"] = "legacy_webdav"
|
|
with self.db.transaction() as conn:
|
|
conn.execute(
|
|
"UPDATE sources SET config_json=?,updated_at=? WHERE id=?",
|
|
(json.dumps(config), utcnow(), source_id),
|
|
)
|
|
with self._clients_guard:
|
|
cached = self._clients.pop(source_id, None)
|
|
if cached:
|
|
cached[1].close()
|
|
return {"enabled": False, "storage_backend": "legacy_webdav"}
|
|
|
|
result = self.probe(source_id, values)
|
|
candidate = self._candidate(source_id, values)
|
|
config = dict(candidate["config"])
|
|
config.update(
|
|
{
|
|
"storage_backend": "openlist_native",
|
|
"writable": True,
|
|
"openlist_tested_at": utcnow(),
|
|
"openlist_test_message": result["message"],
|
|
}
|
|
)
|
|
secret_blob = self.secrets.encrypt_json(candidate["secrets"])
|
|
with self.db.transaction() as conn:
|
|
conn.execute(
|
|
"UPDATE sources SET config_json=?,secret_blob=?,updated_at=? WHERE id=?",
|
|
(json.dumps(config), secret_blob, utcnow(), source_id),
|
|
)
|
|
return {
|
|
"enabled": True,
|
|
"storage_backend": "openlist_native",
|
|
"tested_at": config["openlist_tested_at"],
|
|
"message": result["message"],
|
|
}
|
|
|
|
@staticmethod
|
|
def _share_staged_file(job_root: Path, local_file: Path) -> None:
|
|
"""Grant the separately packaged OpenList service read access.
|
|
|
|
fnOS applications run under different service users. rclone creates
|
|
crypt output with private modes, so OpenList can stat it through its
|
|
local storage driver but its background worker cannot open it. The
|
|
staging tree is short-lived (and encrypted in crypt mode); expose only
|
|
this upload subtree and remove it after promotion.
|
|
"""
|
|
|
|
root = job_root.resolve(strict=True)
|
|
file_path = local_file.resolve(strict=True)
|
|
file_path.relative_to(root)
|
|
if local_file.is_symlink():
|
|
raise OSError("OpenList 本地中转文件不能是符号链接")
|
|
current = file_path.parent
|
|
directories: list[Path] = []
|
|
while True:
|
|
directories.append(current)
|
|
if current == root:
|
|
break
|
|
current = current.parent
|
|
for directory in reversed(directories):
|
|
if directory.is_symlink():
|
|
raise OSError("OpenList 本地中转目录不能包含符号链接")
|
|
directory.chmod(0o755)
|
|
file_path.chmod(0o644)
|
|
|
|
def prepare(self, source_id: str, upload_id: str, target_key: str, input_path: Path) -> dict:
|
|
source = self.sources.get(source_id)
|
|
config = self.configuration(source)
|
|
job_root = config.local_staging_path / upload_id
|
|
job_root.mkdir(parents=True, exist_ok=True)
|
|
os.chmod(job_root, 0o700)
|
|
target_key = safe_relative_path(target_key, allow_empty=False)
|
|
if config.encrypted:
|
|
encrypted_root = job_root / "encrypted"
|
|
encrypted_file = self.sources.rclone.encrypt_to_local(source, input_path, target_key, encrypted_root)
|
|
input_path.unlink(missing_ok=True)
|
|
physical_relative = encrypted_file.relative_to(encrypted_root).as_posix()
|
|
local_file = encrypted_file
|
|
source_path = _remote_join(config.source_path, upload_id, "encrypted", physical_relative)
|
|
target_path = _remote_join(config.target_path, physical_relative)
|
|
else:
|
|
direct_root = job_root / "direct"
|
|
direct_root.mkdir(parents=True, exist_ok=True)
|
|
local_file = direct_root / PurePosixPath(target_key).name
|
|
if input_path.resolve() != local_file.resolve():
|
|
os.replace(input_path, local_file)
|
|
physical_relative = target_key
|
|
source_path = _remote_join(config.source_path, upload_id, "direct", local_file.name)
|
|
target_path = _remote_join(config.target_path, target_key)
|
|
staged_path = _remote_join(
|
|
config.target_path,
|
|
f".imagefind-staging-{upload_id}",
|
|
PurePosixPath(target_path).name,
|
|
)
|
|
self._share_staged_file(job_root, local_file)
|
|
return {
|
|
"local_path": str(local_file),
|
|
"size_bytes": local_file.stat().st_size,
|
|
"source_path": source_path,
|
|
"staged_path": staged_path,
|
|
"target_path": target_path,
|
|
"physical_relative": physical_relative,
|
|
}
|
|
|
|
def submit(self, source_id: str, source_path: str, staged_path: str, expected_size: int) -> str:
|
|
client = self.client(source_id)
|
|
source = client.object_info(source_path)
|
|
if _object_size(source) != expected_size:
|
|
raise RuntimeError("OpenList 尚未看到完整的本地中转文件")
|
|
client.ensure_directory(_remote_parent(staged_path))
|
|
staged = client.object_info(staged_path)
|
|
if _object_size(staged) == expected_size:
|
|
return ""
|
|
if staged is not None:
|
|
client.remove(staged_path)
|
|
tasks = client.copy_file(source_path, staged_path)
|
|
return tasks[0] if tasks else ""
|
|
|
|
def task_info(self, source_id: str, task_id: str) -> dict | None:
|
|
return self.client(source_id).copy_task_info(task_id)
|
|
|
|
def verify(self, source_id: str, path: str, expected_size: int) -> bool:
|
|
return _object_size(self.client(source_id).object_info(path)) == expected_size
|
|
|
|
def target_exists_for_key(self, source_id: str, key: str) -> bool | None:
|
|
"""Return whether a native upload still exists at its physical target.
|
|
|
|
``None`` means the logical key was not created by the native upload
|
|
pipeline, so callers must keep their normal conservative behavior.
|
|
This is used to reconcile a WebDAV MOVE whose response and decrypted
|
|
directory cache are ambiguous even though OpenList already moved the
|
|
physical object away from its original path.
|
|
"""
|
|
logical_key = safe_relative_path(key, allow_empty=False)
|
|
if not self.enabled(source_id):
|
|
return None
|
|
with self.db.read() as conn:
|
|
row = conn.execute(
|
|
"SELECT physical_path AS external_target_path FROM videos WHERE source_id=? AND source_key=? "
|
|
"AND storage_backend='openlist_native' AND physical_path IS NOT NULL "
|
|
"UNION ALL SELECT external_target_path FROM uploads WHERE source_id=? AND target_key=? "
|
|
"AND storage_backend='openlist_native' AND external_target_path IS NOT NULL "
|
|
"ORDER BY external_target_path NULLS LAST LIMIT 1",
|
|
(source_id, logical_key, source_id, logical_key),
|
|
).fetchone()
|
|
if not row or not row["external_target_path"]:
|
|
return None
|
|
return self.client(source_id).object_info(str(row["external_target_path"])) is not None
|
|
|
|
def physical_object_for_key(self, source_id: str, key: str) -> tuple[str, int, int] | None:
|
|
"""Resolve a durable native object mapping for deletion and restore."""
|
|
|
|
logical_key = safe_relative_path(key, allow_empty=False)
|
|
if not self.enabled(source_id):
|
|
return None
|
|
with self.db.read() as conn:
|
|
row = conn.execute(
|
|
"SELECT physical_path,physical_size_bytes,size_bytes FROM videos WHERE source_id=? AND source_key=? "
|
|
"AND storage_backend='openlist_native' AND physical_path IS NOT NULL",
|
|
(source_id, logical_key),
|
|
).fetchone()
|
|
if row is None:
|
|
row = conn.execute(
|
|
"SELECT external_target_path AS physical_path,"
|
|
"external_size_bytes AS physical_size_bytes,size_bytes "
|
|
"FROM uploads WHERE source_id=? AND target_key=? AND storage_backend='openlist_native' "
|
|
"AND external_target_path IS NOT NULL ORDER BY updated_at DESC LIMIT 1",
|
|
(source_id, logical_key),
|
|
).fetchone()
|
|
if row is None or not row["physical_path"]:
|
|
return None
|
|
return (
|
|
str(row["physical_path"]),
|
|
int(row["physical_size_bytes"] or 0),
|
|
int(row["size_bytes"] or 0),
|
|
)
|
|
|
|
@staticmethod
|
|
def _verified_object_size(client: AlistClient, path: str) -> int | None:
|
|
return _object_size(client.object_info(path))
|
|
|
|
def trash_object(
|
|
self,
|
|
source_id: str,
|
|
trash_id: str,
|
|
physical_path: str,
|
|
expected_size: int,
|
|
) -> str:
|
|
"""Move one native object into an isolated recoverable directory."""
|
|
|
|
source = self.sources.get(source_id)
|
|
config = self.configuration(source)
|
|
original = safe_relative_path(physical_path, allow_empty=False)
|
|
trash_directory = _remote_join(config.target_path, ".imagefind-native-trash", trash_id)
|
|
target = _remote_join(trash_directory, PurePosixPath(original).name)
|
|
client = self.client(source_id, source)
|
|
if client.object_info(target) is not None:
|
|
raise RuntimeError("OpenList 原生回收站目标已存在")
|
|
client.ensure_directory(trash_directory)
|
|
try:
|
|
client.move_file(original, trash_directory, overwrite=False)
|
|
except Exception:
|
|
# OpenList can finish a provider-side move after the control-plane
|
|
# request times out. Resolve that ambiguous response from object
|
|
# state before deciding whether compensation is required.
|
|
if client.object_info(original) is not None or self._verified_object_size(client, target) != expected_size:
|
|
raise
|
|
if client.object_info(original) is not None:
|
|
raise RuntimeError("OpenList 原生回收站移动后源对象仍然存在")
|
|
if self._verified_object_size(client, target) != expected_size:
|
|
raise RuntimeError("OpenList 原生回收站对象长度不一致")
|
|
if config.encrypted:
|
|
self.sources.rclone.stop(source_id)
|
|
return target
|
|
|
|
def restore_object(
|
|
self,
|
|
source_id: str,
|
|
physical_trash_path: str,
|
|
physical_original_path: str,
|
|
expected_size: int,
|
|
) -> None:
|
|
source = self.sources.get(source_id)
|
|
config = self.configuration(source)
|
|
trash_path = safe_relative_path(physical_trash_path, allow_empty=False)
|
|
original = safe_relative_path(physical_original_path, allow_empty=False)
|
|
client = self.client(source_id, source)
|
|
if client.object_info(original) is not None:
|
|
raise FileExistsError("原物理路径已被占用,回收站对象保持不变")
|
|
if self._verified_object_size(client, trash_path) != expected_size:
|
|
raise RuntimeError("OpenList 回收站对象不存在或长度不一致")
|
|
client.ensure_directory(_remote_parent(original))
|
|
try:
|
|
client.move_file(trash_path, _remote_parent(original), overwrite=False)
|
|
except Exception:
|
|
if (
|
|
client.object_info(trash_path) is not None
|
|
or self._verified_object_size(client, original) != expected_size
|
|
):
|
|
raise
|
|
if client.object_info(trash_path) is not None or self._verified_object_size(client, original) != expected_size:
|
|
raise RuntimeError("OpenList 回收站恢复后对象状态不一致")
|
|
try:
|
|
client.remove(str(PurePosixPath(trash_path).parent))
|
|
except Exception:
|
|
pass
|
|
if config.encrypted:
|
|
self.sources.rclone.stop(source_id)
|
|
|
|
def purge_object(self, source_id: str, physical_trash_path: str) -> None:
|
|
source = self.sources.get(source_id)
|
|
config = self.configuration(source)
|
|
path = safe_relative_path(physical_trash_path, allow_empty=False)
|
|
client = self.client(source_id, source)
|
|
client.remove(path)
|
|
if client.object_info(path) is not None:
|
|
raise RuntimeError("OpenList 原生回收站对象永久删除后仍然存在")
|
|
try:
|
|
client.remove(str(PurePosixPath(path).parent))
|
|
except Exception:
|
|
pass
|
|
if config.encrypted:
|
|
self.sources.rclone.stop(source_id)
|
|
|
|
def catalog_item(self, source_id: str, key: str, upload: dict) -> SourceItem:
|
|
"""Build a logical item from the object promoted by OpenList.
|
|
|
|
Native uploads keep the OpenList object path separate from the
|
|
user-visible ``videos.source_key``. This is essential for encrypted
|
|
libraries, where rclone-crypt turns the filename into an opaque value;
|
|
a WebDAV PROPFIND of ``key`` would therefore return 404.
|
|
"""
|
|
physical_path = str(upload.get("external_target_path") or "")
|
|
if not physical_path:
|
|
raise RuntimeError("OpenList 上传缺少最终对象路径")
|
|
expected_external = int(upload.get("external_size_bytes") or upload.get("size_bytes") or 0)
|
|
obj = self.client(source_id).object_info(physical_path)
|
|
actual_external = _object_size(obj)
|
|
if obj is None or actual_external is None or actual_external != expected_external:
|
|
raise RuntimeError("OpenList 最终对象不存在或长度不一致")
|
|
|
|
source = self.sources.get(source_id)
|
|
logical_key = safe_relative_path(key, allow_empty=False)
|
|
logical_size = int(upload.get("size_bytes") or 0)
|
|
content_sha256 = str(upload.get("content_sha256") or "")
|
|
object_identity = (
|
|
obj.get("sign")
|
|
or obj.get("hash")
|
|
or obj.get("etag")
|
|
or obj.get("modified")
|
|
or obj.get("updated_at")
|
|
or actual_external
|
|
)
|
|
fingerprint = _fingerprint(content_sha256 or logical_key, object_identity, logical_size)
|
|
# OpenList promotes the encrypted object outside rclone's process.
|
|
# Drop an existing crypt WebDAV instance before creating the logical
|
|
# record so its VFS directory cache cannot retain a pre-promotion 404.
|
|
# A new instance is started lazily by remote_access below.
|
|
if source["config"].get("mode") == "encrypted":
|
|
self.sources.rclone.stop(source_id)
|
|
location, _, _, _ = self.sources.remote_access(source_id, logical_key)
|
|
return SourceItem(
|
|
key=logical_key,
|
|
display_name=PurePosixPath(logical_key).name,
|
|
location=location,
|
|
size_bytes=logical_size,
|
|
modified_at=str(obj.get("modified") or obj.get("updated_at") or "") or None,
|
|
etag=str(obj.get("sign") or obj.get("etag") or "") or None,
|
|
fingerprint=fingerprint,
|
|
)
|
|
|
|
def promote(self, source_id: str, upload_id: str, staged_path: str, target_path: str, expected_size: int) -> None:
|
|
client = self.client(source_id)
|
|
target = client.object_info(target_path)
|
|
backup_path = ""
|
|
if target is not None:
|
|
backup_name = f".imagefind-old-{upload_id[:8]}-{PurePosixPath(target_path).name}"
|
|
client.rename(target_path, backup_name)
|
|
backup_path = _remote_join(_remote_parent(target_path), backup_name)
|
|
try:
|
|
client.ensure_directory(_remote_parent(target_path))
|
|
client.move_file(staged_path, _remote_parent(target_path), overwrite=False)
|
|
if not self.verify(source_id, target_path, expected_size):
|
|
raise RuntimeError("OpenList 暂存文件提升后最终目标长度不一致")
|
|
if backup_path:
|
|
client.remove(backup_path)
|
|
except Exception:
|
|
if backup_path and client.object_info(target_path) is None and client.object_info(backup_path) is not None:
|
|
client.rename(backup_path, PurePosixPath(target_path).name)
|
|
raise
|
|
|
|
def cleanup(self, source_id: str, upload_id: str, staged_path: str | None = None) -> None:
|
|
source = self.sources.get(source_id)
|
|
config = self.configuration(source)
|
|
if staged_path:
|
|
stage_directory = str(PurePosixPath(staged_path).parent)
|
|
try:
|
|
self.client(source_id).remove(stage_directory)
|
|
except Exception:
|
|
pass
|
|
job_root = config.local_staging_path / upload_id
|
|
shutil.rmtree(job_root, ignore_errors=True)
|
|
|
|
def cancel(self, source_id: str, upload_id: str, task_id: str | None, staged_path: str | None) -> None:
|
|
try:
|
|
if task_id:
|
|
self.client(source_id).cancel_copy_task(task_id)
|
|
finally:
|
|
self.cleanup(source_id, upload_id, staged_path)
|