This commit is contained in:
nose
2025-12-13 00:18:30 -08:00
parent 85750247cc
commit 30eb628aa3
18 changed files with 1056 additions and 407 deletions

View File

@@ -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

View File

@@ -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}")

View File

@@ -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())