Files
imagefind/backend/imagefind/openlist_native.py
T

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)