hh
This commit is contained in:
@@ -34,8 +34,25 @@ class Folder(Store):
|
||||
""""""
|
||||
# Track which locations have already been migrated to avoid repeated migrations
|
||||
_migrated_locations = set()
|
||||
|
||||
def __new__(cls, *args: Any, **kwargs: Any) -> "Folder":
|
||||
return super().__new__(cls)
|
||||
|
||||
setattr(__new__, "keys", ("NAME", "PATH"))
|
||||
|
||||
def __init__(self, location: Optional[str] = None, name: Optional[str] = None) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
location: Optional[str] = None,
|
||||
name: Optional[str] = None,
|
||||
*,
|
||||
NAME: Optional[str] = None,
|
||||
PATH: Optional[str] = None,
|
||||
) -> None:
|
||||
if name is None and NAME is not None:
|
||||
name = str(NAME)
|
||||
if location is None and PATH is not None:
|
||||
location = str(PATH)
|
||||
|
||||
self._location = location
|
||||
self._name = name
|
||||
|
||||
|
||||
@@ -17,8 +17,32 @@ class HydrusNetwork(Store):
|
||||
Each instance represents a specific Hydrus client connection.
|
||||
Maintains its own HydrusClient with session key.
|
||||
"""
|
||||
|
||||
def __new__(cls, *args: Any, **kwargs: Any) -> "HydrusNetwork":
|
||||
instance = super().__new__(cls)
|
||||
name = kwargs.get("NAME")
|
||||
api = kwargs.get("API")
|
||||
url = kwargs.get("URL")
|
||||
if name is not None:
|
||||
setattr(instance, "NAME", str(name))
|
||||
if api is not None:
|
||||
setattr(instance, "API", str(api))
|
||||
if url is not None:
|
||||
setattr(instance, "URL", str(url))
|
||||
return instance
|
||||
|
||||
setattr(__new__, "keys", ("NAME", "API", "URL"))
|
||||
|
||||
def __init__(self, instance_name: str, api_key: str, url: str) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
instance_name: Optional[str] = None,
|
||||
api_key: Optional[str] = None,
|
||||
url: Optional[str] = None,
|
||||
*,
|
||||
NAME: Optional[str] = None,
|
||||
API: Optional[str] = None,
|
||||
URL: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Initialize Hydrus storage backend.
|
||||
|
||||
Args:
|
||||
@@ -27,18 +51,41 @@ class HydrusNetwork(Store):
|
||||
url: Hydrus client URL (e.g., 'http://192.168.1.230:45869')
|
||||
"""
|
||||
from API.HydrusNetwork import HydrusNetwork as HydrusClient
|
||||
|
||||
if instance_name is None and NAME is not None:
|
||||
instance_name = str(NAME)
|
||||
if api_key is None and API is not None:
|
||||
api_key = str(API)
|
||||
if url is None and URL is not None:
|
||||
url = str(URL)
|
||||
|
||||
if not instance_name or not api_key or not url:
|
||||
raise ValueError("HydrusNetwork requires NAME, API, and URL")
|
||||
|
||||
self._instance_name = instance_name
|
||||
self._api_key = api_key
|
||||
self._url = url
|
||||
self.NAME = instance_name
|
||||
self.API = api_key
|
||||
self.URL = url
|
||||
# Create persistent client with session key for this instance
|
||||
self._client = HydrusClient(url=url, access_key=api_key)
|
||||
|
||||
# Self health-check: acquire a session key immediately so broken configs
|
||||
# fail-fast and the registry can skip registering this backend.
|
||||
try:
|
||||
if self._client is not None:
|
||||
self._client.ensure_session_key()
|
||||
except Exception as exc:
|
||||
# Best-effort cleanup so partially constructed objects don't linger.
|
||||
try:
|
||||
self._client = None
|
||||
except Exception:
|
||||
pass
|
||||
raise RuntimeError(f"Hydrus '{self.NAME}' unavailable: {exc}") from exc
|
||||
|
||||
def name(self) -> str:
|
||||
return self._instance_name
|
||||
return self.NAME
|
||||
|
||||
def get_name(self) -> str:
|
||||
return self._instance_name
|
||||
return self.NAME
|
||||
|
||||
def add_file(self, file_path: Path, **kwargs: Any) -> str:
|
||||
"""Upload file to Hydrus with full metadata support.
|
||||
@@ -281,7 +328,7 @@ class HydrusNetwork(Store):
|
||||
if has_namespace:
|
||||
# Explicit namespace search - already filtered by Hydrus tag search
|
||||
# Include this result as-is
|
||||
file_url = f"{self._url.rstrip('/')}/get_files/file?hash={hash_hex}"
|
||||
file_url = f"{self.URL.rstrip('/')}/get_files/file?hash={hash_hex}"
|
||||
results.append({
|
||||
"hash": hash_hex,
|
||||
"url": file_url,
|
||||
@@ -289,7 +336,7 @@ class HydrusNetwork(Store):
|
||||
"title": title,
|
||||
"size": size,
|
||||
"size_bytes": size,
|
||||
"store": self._instance_name,
|
||||
"store": self.NAME,
|
||||
"tag": all_tags,
|
||||
"file_id": file_id,
|
||||
"mime": mime_type,
|
||||
@@ -314,7 +361,7 @@ class HydrusNetwork(Store):
|
||||
break
|
||||
|
||||
if match:
|
||||
file_url = f"{self._url.rstrip('/')}/get_files/file?hash={hash_hex}"
|
||||
file_url = f"{self.URL.rstrip('/')}/get_files/file?hash={hash_hex}"
|
||||
results.append({
|
||||
"hash": hash_hex,
|
||||
"url": file_url,
|
||||
@@ -322,7 +369,7 @@ class HydrusNetwork(Store):
|
||||
"title": title,
|
||||
"size": size,
|
||||
"size_bytes": size,
|
||||
"store": self._instance_name,
|
||||
"store": self.NAME,
|
||||
"tag": all_tags,
|
||||
"file_id": file_id,
|
||||
"mime": mime_type,
|
||||
@@ -345,8 +392,8 @@ class HydrusNetwork(Store):
|
||||
debug(f"[HydrusNetwork.get_file] Starting for hash: {file_hash[:12]}...")
|
||||
|
||||
# Build browser URL with access key
|
||||
base_url = self._client.url.rstrip('/')
|
||||
access_key = self._client.access_key
|
||||
base_url = str(self.URL).rstrip('/')
|
||||
access_key = str(self.API)
|
||||
browser_url = f"{base_url}/get_files/file?hash={file_hash}&Hydrus-Client-API-Access-Key={access_key}"
|
||||
debug(f"[HydrusNetwork.get_file] Opening URL: {browser_url}")
|
||||
|
||||
|
||||
@@ -3,31 +3,107 @@
|
||||
Concrete store implementations live in the `Store/` package.
|
||||
This module is the single source of truth for store discovery.
|
||||
|
||||
Config schema (canonical):
|
||||
|
||||
{
|
||||
"store": {
|
||||
"folder": {
|
||||
"default": {"path": "C:/Media"},
|
||||
"test": {"path": "C:/Temp"}
|
||||
},
|
||||
"hydrusnetwork": {
|
||||
"home": {"Hydrus-Client-API-Access-Key": "...", "url": "http://..."}
|
||||
}
|
||||
}
|
||||
}
|
||||
This registry is config-driven:
|
||||
- Each store subtype (e.g. `hydrusnetwork`) maps to a concrete store class.
|
||||
- Each store class advertises its required config keys via `StoreClass.__new__.keys`.
|
||||
- Instances are created from config using those keys (case-insensitive lookup).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import inspect
|
||||
import pkgutil
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
from typing import Any, Dict, Iterable, Optional, Type
|
||||
|
||||
from SYS.logger import debug
|
||||
|
||||
from Store._base import Store as BaseStore
|
||||
from Store.Folder import Folder
|
||||
from Store.HydrusNetwork import HydrusNetwork
|
||||
|
||||
|
||||
def _normalize_store_type(value: str) -> str:
|
||||
return "".join(ch.lower() for ch in str(value or "") if ch.isalnum())
|
||||
|
||||
|
||||
def _normalize_config_key(value: str) -> str:
|
||||
return str(value or "").strip().upper()
|
||||
|
||||
|
||||
def _get_case_insensitive(mapping: Dict[str, Any], key: str) -> Any:
|
||||
if key in mapping:
|
||||
return mapping[key]
|
||||
desired = _normalize_config_key(key)
|
||||
for k, v in mapping.items():
|
||||
if _normalize_config_key(k) == desired:
|
||||
return v
|
||||
return None
|
||||
|
||||
|
||||
def _discover_store_classes() -> Dict[str, Type[BaseStore]]:
|
||||
"""Discover store classes from the Store package.
|
||||
|
||||
Convention:
|
||||
- The store type key is the normalized class name (e.g. HydrusNetwork -> hydrusnetwork).
|
||||
"""
|
||||
import Store as store_pkg
|
||||
|
||||
discovered: Dict[str, Type[BaseStore]] = {}
|
||||
for module_info in pkgutil.iter_modules(store_pkg.__path__):
|
||||
module_name = module_info.name
|
||||
if module_name in {"__init__", "_base", "registry"}:
|
||||
continue
|
||||
|
||||
module = importlib.import_module(f"Store.{module_name}")
|
||||
for _, obj in vars(module).items():
|
||||
if not inspect.isclass(obj):
|
||||
continue
|
||||
if obj is BaseStore:
|
||||
continue
|
||||
if not issubclass(obj, BaseStore):
|
||||
continue
|
||||
discovered[_normalize_store_type(obj.__name__)] = obj
|
||||
return discovered
|
||||
|
||||
|
||||
def _required_keys_for(store_cls: Type[BaseStore]) -> list[str]:
|
||||
keys = getattr(store_cls.__new__, "keys", None)
|
||||
if keys is None:
|
||||
return []
|
||||
if isinstance(keys, dict):
|
||||
return [str(k) for k in keys.keys()]
|
||||
if isinstance(keys, (list, tuple, set, frozenset)):
|
||||
return [str(k) for k in keys]
|
||||
if isinstance(keys, str):
|
||||
return [keys]
|
||||
raise TypeError(f"Unsupported __new__.keys type for {store_cls.__name__}: {type(keys)}")
|
||||
|
||||
|
||||
def _build_kwargs(store_cls: Type[BaseStore], instance_name: str, instance_config: Any) -> Dict[str, Any]:
|
||||
if isinstance(instance_config, dict):
|
||||
cfg_dict = dict(instance_config)
|
||||
else:
|
||||
cfg_dict = {}
|
||||
|
||||
required = _required_keys_for(store_cls)
|
||||
|
||||
# If NAME is required but not present, allow the instance key to provide it.
|
||||
if any(_normalize_config_key(k) == "NAME" for k in required) and _get_case_insensitive(cfg_dict, "NAME") is None:
|
||||
cfg_dict["NAME"] = str(instance_name)
|
||||
|
||||
kwargs: Dict[str, Any] = {}
|
||||
missing: list[str] = []
|
||||
for key in required:
|
||||
value = _get_case_insensitive(cfg_dict, key)
|
||||
if value is None or value == "":
|
||||
missing.append(str(key))
|
||||
continue
|
||||
kwargs[str(key)] = value
|
||||
|
||||
if missing:
|
||||
raise ValueError(f"Missing required keys for {store_cls.__name__}: {', '.join(missing)}")
|
||||
|
||||
return kwargs
|
||||
|
||||
|
||||
class Store:
|
||||
@@ -42,43 +118,36 @@ class Store:
|
||||
if not isinstance(store_cfg, dict):
|
||||
store_cfg = {}
|
||||
|
||||
folder_cfg = store_cfg.get("folder")
|
||||
if isinstance(folder_cfg, dict):
|
||||
for name, value in folder_cfg.items():
|
||||
path_val: Optional[str]
|
||||
if isinstance(value, dict):
|
||||
path_val = value.get("path")
|
||||
elif isinstance(value, (str, bytes)):
|
||||
path_val = str(value)
|
||||
else:
|
||||
path_val = None
|
||||
classes_by_type = _discover_store_classes()
|
||||
for raw_store_type, instances in store_cfg.items():
|
||||
if not isinstance(instances, dict):
|
||||
continue
|
||||
|
||||
if not path_val:
|
||||
continue
|
||||
|
||||
location = str(Path(str(path_val)).expanduser())
|
||||
self._backends[str(name)] = Folder(location=location, name=str(name))
|
||||
|
||||
hydrus_cfg = store_cfg.get("hydrusnetwork")
|
||||
if isinstance(hydrus_cfg, dict):
|
||||
for instance_name, instance_config in hydrus_cfg.items():
|
||||
if not isinstance(instance_config, dict):
|
||||
continue
|
||||
|
||||
api_key = instance_config.get("Hydrus-Client-API-Access-Key")
|
||||
url = instance_config.get("url")
|
||||
if not api_key or not url:
|
||||
continue
|
||||
store_type = _normalize_store_type(str(raw_store_type))
|
||||
store_cls = classes_by_type.get(store_type)
|
||||
if store_cls is None:
|
||||
if not self._suppress_debug:
|
||||
debug(f"[Store] Unknown store type '{raw_store_type}'")
|
||||
continue
|
||||
|
||||
for instance_name, instance_config in instances.items():
|
||||
try:
|
||||
self._backends[str(instance_name)] = HydrusNetwork(
|
||||
instance_name=str(instance_name),
|
||||
api_key=str(api_key),
|
||||
url=str(url),
|
||||
)
|
||||
kwargs = _build_kwargs(store_cls, str(instance_name), instance_config)
|
||||
|
||||
# Convenience normalization for filesystem-like paths.
|
||||
for key in list(kwargs.keys()):
|
||||
if _normalize_config_key(key) in {"PATH", "LOCATION"}:
|
||||
kwargs[key] = str(Path(str(kwargs[key])).expanduser())
|
||||
|
||||
backend = store_cls(**kwargs)
|
||||
|
||||
backend_name = str(kwargs.get("NAME") or instance_name)
|
||||
self._backends[backend_name] = backend
|
||||
except Exception as exc:
|
||||
if not self._suppress_debug:
|
||||
debug(f"[Store] Failed to register Hydrus instance '{instance_name}': {exc}")
|
||||
debug(
|
||||
f"[Store] Failed to register {store_cls.__name__} instance '{instance_name}': {exc}"
|
||||
)
|
||||
|
||||
def list_backends(self) -> list[str]:
|
||||
return sorted(self._backends.keys())
|
||||
|
||||
Reference in New Issue
Block a user