From 5985a8306af0fab2c115b594273926b270e437cf Mon Sep 17 00:00:00 2001 From: Nose Date: Sun, 11 Jan 2026 03:24:49 -0800 Subject: [PATCH] kk --- Provider/alldebrid.py | 12 +++ Provider/fileio.py | 27 ++++++ Provider/internetarchive.py | 34 ++++++-- Provider/matrix.py | 23 ++++++ Provider/openlibrary.py | 129 +++++++++++++---------------- Provider/soulseek.py | 18 ++++ Provider/telegram.py | 24 ++++++ ProviderCore/base.py | 15 ++++ Store/Folder.py | 20 ++++- Store/HydrusNetwork.py | 28 ++++++- Store/_base.py | 14 ++++ Store/registry.py | 17 ++++ TUI/modalscreen/config_modal.py | 141 ++++++++++++++++++++++++++++---- 13 files changed, 401 insertions(+), 101 deletions(-) diff --git a/Provider/alldebrid.py b/Provider/alldebrid.py index 6bc5dfd..ff695da 100644 --- a/Provider/alldebrid.py +++ b/Provider/alldebrid.py @@ -568,6 +568,18 @@ class AllDebrid(TableProviderMixin, Provider): URL = ("magnet:",) URL_DOMAINS = () + @classmethod + def config(cls) -> List[Dict[str, Any]]: + return [ + { + "key": "api_key", + "label": "API Key", + "default": "", + "required": True, + "secret": True + } + ] + @staticmethod def _resolve_magnet_spec_from_result(result: Any) -> Optional[str]: table = getattr(result, "table", None) diff --git a/Provider/fileio.py b/Provider/fileio.py index a06b635..113eaff 100644 --- a/Provider/fileio.py +++ b/Provider/fileio.py @@ -50,6 +50,33 @@ def _extract_key(payload: Any) -> Optional[str]: class FileIO(Provider): """File provider for file.io.""" + PROVIDER_NAME = "file.io" + + @classmethod + def config(cls) -> List[Dict[str, Any]]: + return [ + { + "key": "api_key", + "label": "API Key", + "default": "", + "secret": True + }, + { + "key": "expires", + "label": "Default Expiration (e.g. 1w)", + "default": "1w" + }, + { + "key": "maxDownloads", + "label": "Max Downloads", + "default": 1 + }, + { + "key": "autoDelete", + "label": "Auto Delete", + "default": True + } + ] def __init__(self, config: Optional[Dict[str, Any]] = None): super().__init__(config) diff --git a/Provider/internetarchive.py b/Provider/internetarchive.py index cc4d642..6dbbba5 100644 --- a/Provider/internetarchive.py +++ b/Provider/internetarchive.py @@ -464,16 +464,36 @@ class InternetArchive(Provider): - search-file -provider internetarchive - download-file / provider.download() from search results - add-file -provider internetarchive (uploads) - - Config (optional): - [provider=internetarchive] - access_key="..." # optional (upload) - secret_key="..." # optional (upload) - collection="..." # optional (upload) - mediatype="..." # optional (upload) """ URL = ("archive.org",) + @classmethod + def config(cls) -> List[Dict[str, Any]]: + return [ + { + "key": "access_key", + "label": "Access Key (for uploads)", + "default": "", + "secret": True + }, + { + "key": "secret_key", + "label": "Secret Key (for uploads)", + "default": "", + "secret": True + }, + { + "key": "collection", + "label": "Default Collection", + "default": "" + }, + { + "key": "mediatype", + "label": "Default Mediatype", + "default": "" + } + ] + TABLE_AUTO_STAGES = { "internetarchive": ["download-file"], "internetarchive.folder": ["download-file"], diff --git a/Provider/matrix.py b/Provider/matrix.py index 3190828..4199b33 100644 --- a/Provider/matrix.py +++ b/Provider/matrix.py @@ -234,6 +234,29 @@ class Matrix(TableProviderMixin, Provider): 4. Selection triggers upload of pending files to selected rooms """ + @classmethod + def config(cls) -> List[Dict[str, Any]]: + return [ + { + "key": "homeserver", + "label": "Homeserver URL", + "default": "https://matrix.org", + "required": True + }, + { + "key": "access_token", + "label": "Access Token", + "default": "", + "secret": True + }, + { + "key": "password", + "label": "Password (fallback)", + "default": "", + "secret": True + } + ] + def __init__(self, config: Optional[Dict[str, Any]] = None): super().__init__(config) self._init_ok: Optional[bool] = None diff --git a/Provider/openlibrary.py b/Provider/openlibrary.py index 88f8de8..c441813 100644 --- a/Provider/openlibrary.py +++ b/Provider/openlibrary.py @@ -28,6 +28,13 @@ from Provider.metadata_provider import ( from SYS.utils import unique_path _ARCHIVE_VERIFY_VALUE = get_requests_verify_value() +_DEFAULT_ARCHIVE_SCALE = 4 +_QUALITY_TO_ARCHIVE_SCALE = { + "high": 2, + "medium": 5, + "low": 8, +} + def _create_archive_session() -> requests.Session: session = requests.Session() @@ -279,17 +286,30 @@ class OpenLibrary(Provider): "openlibrary": ["download-file"], } - REQUIRED_CONFIG_KEYS = ( - "email", - "password", - ) + @classmethod + def config(cls) -> List[Dict[str, Any]]: + return [ + { + "key": "email", + "label": "Archive.org Email", + "default": "", + "required": True + }, + { + "key": "password", + "label": "Archive.org Password", + "default": "", + "required": True, + "secret": True + }, + { + "key": "quality", + "label": "Image Quality", + "default": "medium", + "placeholder": "high, medium, low" + } + ] - DEFAULT_ARCHIVE_SCALE = 4 - QUALITY_TO_ARCHIVE_SCALE = { - "high": 2, - "medium": 5, - "low": 8, - } # Domains that should be routed to this provider when the user supplies a URL. # (Used by ProviderCore.registry.match_provider_name_for_url) URL_DOMAINS = ( @@ -342,88 +362,53 @@ class OpenLibrary(Provider): } @staticmethod - def _credential_archive(config: Dict[str, - Any]) -> Tuple[Optional[str], - Optional[str]]: - """Get Archive.org email/password from config. - - Supports: - - New: {"provider": {"openlibrary": {"email": "...", "password": "..."}}} - - Old: {"Archive": {"email": "...", "password": "..."}} - {"archive_org_email": "...", "archive_org_password": "..."} - """ + def _credential_archive(config: Dict[str, Any]) -> Tuple[Optional[str], Optional[str]]: + """Get Archive.org email/password from config.""" if not isinstance(config, dict): return None, None - provider_config = config.get("provider", - {}) - if isinstance(provider_config, dict): - openlibrary_config = provider_config.get("openlibrary", - {}) - if isinstance(openlibrary_config, dict): - email = openlibrary_config.get("email") - password = openlibrary_config.get("password") - if email or password: - return str(email) if email is not None else None, ( - str(password) if password is not None else None - ) - - archive_config = config.get("Archive") - if isinstance(archive_config, dict): - email = archive_config.get("email") - password = archive_config.get("password") + entry = config.get("provider", {}).get("openlibrary", {}) + if isinstance(entry, dict): + email = entry.get("email") + password = entry.get("password") if email or password: return str(email) if email is not None else None, ( str(password) if password is not None else None ) - email = config.get("archive_org_email") - password = config.get("archive_org_password") - return str(email) if email is not None else None, ( - str(password) if password is not None else None - ) + return None, None @classmethod def _archive_scale_from_config(cls, config: Dict[str, Any]) -> int: - """Resolve Archive.org book-reader scale from provider config. - - Config: - [provider=OpenLibrary] - quality="medium" # High=2, Medium=5, Low=8 - - Default when missing/invalid: 4. - """ - - default_scale = int(getattr(cls, "DEFAULT_ARCHIVE_SCALE", 4) or 4) + """Resolve Archive.org book-reader scale from provider config.""" if not isinstance(config, dict): - return default_scale + return _DEFAULT_ARCHIVE_SCALE - provider_config = config.get("provider", {}) - openlibrary_config = None - if isinstance(provider_config, dict): - openlibrary_config = provider_config.get("openlibrary") - if not isinstance(openlibrary_config, dict): - openlibrary_config = {} + entry = config.get("provider", {}).get("openlibrary", {}) + if not isinstance(entry, dict): + return _DEFAULT_ARCHIVE_SCALE - raw_quality = openlibrary_config.get("quality") + raw_quality = entry.get("quality") if raw_quality is None: - return default_scale + return _DEFAULT_ARCHIVE_SCALE if isinstance(raw_quality, (int, float)): - try: - val = int(raw_quality) - except Exception: - return default_scale - return val if val > 0 else default_scale + val = int(raw_quality) + return val if val > 0 else _DEFAULT_ARCHIVE_SCALE + + q = str(raw_quality).strip().lower() + if not q: + return _DEFAULT_ARCHIVE_SCALE + + mapped = _QUALITY_TO_ARCHIVE_SCALE.get(q) + if isinstance(mapped, int) and mapped > 0: + return mapped try: - q = str(raw_quality).strip().lower() + val = int(q) + return val if val > 0 else _DEFAULT_ARCHIVE_SCALE except Exception: - return default_scale - if not q: - return default_scale - - mapped = cls.QUALITY_TO_ARCHIVE_SCALE.get(q) + return _DEFAULT_ARCHIVE_SCALE if isinstance(mapped, int) and mapped > 0: return mapped diff --git a/Provider/soulseek.py b/Provider/soulseek.py index 19dc0e4..6098881 100644 --- a/Provider/soulseek.py +++ b/Provider/soulseek.py @@ -210,6 +210,24 @@ class Soulseek(Provider): } """Search provider for Soulseek P2P network.""" + @classmethod + def config(cls) -> List[Dict[str, Any]]: + return [ + { + "key": "username", + "label": "Soulseek Username", + "default": "", + "required": True + }, + { + "key": "password", + "label": "Soulseek Password", + "default": "", + "required": True, + "secret": True + } + ] + MUSIC_EXTENSIONS = { ".flac", ".mp3", diff --git a/Provider/telegram.py b/Provider/telegram.py index e2632cd..a71c3ca 100644 --- a/Provider/telegram.py +++ b/Provider/telegram.py @@ -149,6 +149,30 @@ class Telegram(Provider): """ URL = ("t.me", "telegram.me") + @classmethod + def config(cls) -> List[Dict[str, Any]]: + return [ + { + "key": "app_id", + "label": "API ID (from my.telegram.org)", + "default": "", + "required": True + }, + { + "key": "api_hash", + "label": "API Hash", + "default": "", + "required": True, + "secret": True + }, + { + "key": "bot_token", + "label": "Bot Token (optional)", + "default": "", + "secret": True + } + ] + def __init__(self, config: Optional[Dict[str, Any]] = None): super().__init__(config) telegram_conf = ( diff --git a/ProviderCore/base.py b/ProviderCore/base.py index 9f310c9..b86e698 100644 --- a/ProviderCore/base.py +++ b/ProviderCore/base.py @@ -142,6 +142,21 @@ class Provider(ABC): self.config = config or {} self.name = self.__class__.__name__.lower() + @classmethod + def config(cls) -> List[Dict[str, Any]]: + """Return configuration schema for this provider. + + Returns a list of dicts, each defining a field: + { + "key": "api_key", + "label": "API Key", + "default": "", + "required": True, + "secret": True + } + """ + return [] + @classmethod def required_config_keys(cls) -> List[str]: keys = getattr(cls, "REQUIRED_CONFIG_KEYS", None) diff --git a/Store/Folder.py b/Store/Folder.py index 6d8573e..23a0157 100644 --- a/Store/Folder.py +++ b/Store/Folder.py @@ -42,10 +42,22 @@ class Folder(Store): Dict[str, int]]] = {} - def __new__(cls, *args: Any, **kwargs: Any) -> "Folder": - return super().__new__(cls) - - setattr(__new__, "keys", ("NAME", "PATH")) + @classmethod + def config(cls) -> List[Dict[str, Any]]: + return [ + { + "key": "NAME", + "label": "Store Name", + "default": "", + "required": True + }, + { + "key": "PATH", + "label": "Folder Path", + "default": "", + "required": True + } + ] def __init__( self, diff --git a/Store/HydrusNetwork.py b/Store/HydrusNetwork.py index 439e01b..c7acfbf 100644 --- a/Store/HydrusNetwork.py +++ b/Store/HydrusNetwork.py @@ -29,6 +29,32 @@ class HydrusNetwork(Store): Maintains its own HydrusClient. """ + @classmethod + def config(cls) -> List[Dict[str, Any]]: + return [ + { + "key": "NAME", + "label": "Store Name", + "default": "", + "placeholder": "e.g. home_hydrus", + "required": True + }, + { + "key": "URL", + "label": "Hydrus URL", + "default": "http://127.0.0.1:45869", + "placeholder": "http://127.0.0.1:45869", + "required": True + }, + { + "key": "API", + "label": "API Key", + "default": "", + "required": True, + "secret": True + } + ] + def _log_prefix(self) -> str: store_name = getattr(self, "NAME", None) or "unknown" return f"[hydrusnetwork:{store_name}]" @@ -46,8 +72,6 @@ class HydrusNetwork(Store): setattr(instance, "URL", str(url)) return instance - setattr(__new__, "keys", ("NAME", "API", "URL")) - def __init__( self, instance_name: Optional[str] = None, diff --git a/Store/_base.py b/Store/_base.py index 86ed073..4993461 100644 --- a/Store/_base.py +++ b/Store/_base.py @@ -12,6 +12,20 @@ from typing import Any, Dict, List, Optional, Tuple class Store(ABC): + @classmethod + def config(cls) -> List[Dict[str, Any]]: + """Return configuration schema for this store. + + Returns a list of dicts: + { + "key": "PATH", + "label": "Store Location", + "default": "", + "required": True + } + """ + return [] + @abstractmethod def add_file(self, file_path: Path, **kwargs: Any) -> str: raise NotImplementedError diff --git a/Store/registry.py b/Store/registry.py index e9390ed..9c531cb 100644 --- a/Store/registry.py +++ b/Store/registry.py @@ -80,6 +80,23 @@ def _discover_store_classes() -> Dict[str, Type[BaseStore]]: def _required_keys_for(store_cls: Type[BaseStore]) -> list[str]: + # Support new config() schema + if hasattr(store_cls, "config") and callable(store_cls.config): + try: + schema = store_cls.config() + keys = [] + if isinstance(schema, list): + for field in schema: + if isinstance(field, dict) and field.get("required"): + k = field.get("key") + if k: + keys.append(str(k)) + if keys: + return keys + except Exception: + pass + + # Legacy __new__.keys support keys = getattr(store_cls.__new__, "keys", None) if keys is None: return [] diff --git a/TUI/modalscreen/config_modal.py b/TUI/modalscreen/config_modal.py index 076559c..b4981e3 100644 --- a/TUI/modalscreen/config_modal.py +++ b/TUI/modalscreen/config_modal.py @@ -209,15 +209,41 @@ class ConfigModal(ModalScreen): item_type = str(self.editing_item_type or "") item_name = str(self.editing_item_name or "") + provider_schema_map = {} + # Parse item_type for store-{stype} or just provider if item_type.startswith("store-"): stype = item_type.replace("store-", "") container.mount(Label(f"Editing Store: {item_name} ({stype})", classes="config-label")) section = self.config_data.get("store", {}).get(stype, {}).get(item_name, {}) + + # Fetch Store schema + classes = _discover_store_classes() + if stype in classes: + cls = classes[stype] + if hasattr(cls, "config") and callable(cls.config): + for field_def in cls.config(): + k = field_def.get("key") + if k: + provider_schema_map[k.upper()] = field_def + else: container.mount(Label(f"Editing {item_type.capitalize()}: {item_name}", classes="config-label")) section = self.config_data.get(item_type, {}).get(item_name, {}) + # Fetch Provider schema + if item_type == "provider": + from ProviderCore.registry import get_provider_class + try: + pcls = get_provider_class(item_name) + if pcls and hasattr(pcls, "config") and callable(pcls.config): + for field_def in pcls.config(): + k = field_def.get("key") + if k: + provider_schema_map[k.upper()] = field_def + except Exception: + pass + # Show all existing keys existing_keys_upper = set() for k, v in section.items(): @@ -229,10 +255,43 @@ class ConfigModal(ModalScreen): continue existing_keys_upper.add(k_upper) - container.mount(Label(k)) - container.mount(Input(value=str(v), id=f"item-{k}", classes="config-input")) + # Determine display props from schema + label_text = k + is_secret = False + schema = provider_schema_map.get(k_upper) + if schema: + label_text = schema.get("label") or k + if schema.get("required"): + label_text += " *" + if schema.get("secret"): + is_secret = True + + container.mount(Label(label_text)) + inp = Input(value=str(v), id=f"item-{k}", classes="config-input") + if is_secret: + inp.password = True + container.mount(inp) - # If it's a store, we might have required keys + # Add required/optional fields from schema that are missing + for k_upper, field_def in provider_schema_map.items(): + if k_upper not in existing_keys_upper: + existing_keys_upper.add(k_upper) + key = field_def["key"] + label_text = field_def.get("label") or key + if field_def.get("required"): + label_text += " *" + + default_val = str(field_def.get("default") or "") + inp = Input(value=default_val, id=f"item-{key}", classes="config-input") + if field_def.get("secret"): + inp.password = True + if field_def.get("placeholder"): + inp.placeholder = field_def.get("placeholder") + + container.mount(Label(label_text)) + container.mount(inp) + + # If it's a store, we might have required keys (legacy check fallback) if item_type.startswith("store-"): stype = item_type.replace("store-", "") classes = _discover_store_classes() @@ -244,8 +303,9 @@ class ConfigModal(ModalScreen): container.mount(Label(rk)) container.mount(Input(value="", id=f"item-{rk}", classes="config-input")) - # If it's a provider, we might have required keys + # If it's a provider, we might have required keys (legacy check fallback) if item_type == "provider": + # 2. Legacy required_config_keys from ProviderCore.registry import get_provider_class try: pcls = get_provider_class(item_name) @@ -307,10 +367,28 @@ class ConfigModal(ModalScreen): self.editing_item_type = "provider" self.refresh_view() elif bid == "add-store-btn": - options = list(_discover_store_classes().keys()) + all_classes = _discover_store_classes() + options = [] + for stype, cls in all_classes.items(): + if hasattr(cls, "config") and callable(cls.config): + try: + if cls.config(): + options.append(stype) + except Exception: + pass self.app.push_screen(SelectionModal("Select Store Type", options), callback=self.on_store_type_selected) elif bid == "add-provider-btn": - options = list(list_providers().keys()) + provider_names = list(list_providers().keys()) + options = [] + from ProviderCore.registry import get_provider_class + for ptype in provider_names: + pcls = get_provider_class(ptype) + if pcls and hasattr(pcls, "config") and callable(pcls.config): + try: + if pcls.config(): + options.append(ptype) + except Exception: + pass self.app.push_screen(SelectionModal("Select Provider Type", options), callback=self.on_provider_type_selected) elif bid.startswith("del-store-"): parts = bid.replace("del-store-", "").split("-", 1) @@ -338,10 +416,23 @@ class ConfigModal(ModalScreen): new_config = {"NAME": new_name} classes = _discover_store_classes() if stype in classes: - required = _required_keys_for(classes[stype]) - for rk in required: - if rk.upper() != "NAME": - new_config[rk] = "" + cls = classes[stype] + # Use schema for defaults if present + if hasattr(cls, "config") and callable(cls.config): + for field_def in cls.config(): + key = field_def.get("key") + if key: + val = field_def.get("default", "") + # Don't override NAME if we already set it to new_stype + if key.upper() == "NAME": + continue + new_config[key] = val + else: + # Fallback to required keys list + required = _required_keys_for(cls) + for rk in required: + if rk.upper() != "NAME": + new_config[rk] = "" self.config_data["store"][stype][new_name] = new_config self.editing_item_type = f"store-{stype}" @@ -355,15 +446,22 @@ class ConfigModal(ModalScreen): # For providers, they are usually top-level entries in 'provider' dict if ptype not in self.config_data["provider"]: - # Get required keys if possible from ProviderCore.registry import get_provider_class try: pcls = get_provider_class(ptype) new_config = {} if pcls: - required = pcls.required_config_keys() - for rk in required: - new_config[rk] = "" + # Use schema for defaults + if hasattr(pcls, "config") and callable(pcls.config): + for field_def in pcls.config(): + key = field_def.get("key") + if key: + new_config[key] = field_def.get("default", "") + else: + # Fallback to legacy required keys + required = pcls.required_config_keys() + for rk in required: + new_config[rk] = "" self.config_data["provider"][ptype] = new_config except Exception: self.config_data["provider"][ptype] = {} @@ -420,14 +518,25 @@ class ConfigModal(ModalScreen): stype = item_type.replace("store-", "") classes = _discover_store_classes() if stype in classes: - required_keys = _required_keys_for(classes[stype]) + required_keys = list(_required_keys_for(classes[stype])) section = self.config_data.get("store", {}).get(stype, {}).get(item_name, {}) elif item_type == "provider": from ProviderCore.registry import get_provider_class try: pcls = get_provider_class(item_name) if pcls: - required_keys = pcls.required_config_keys() + # Collect required keys from schema + if hasattr(pcls, "config") and callable(pcls.config): + for field_def in pcls.config(): + if field_def.get("required"): + k = field_def.get("key") + if k and k not in required_keys: + required_keys.append(k) + + # Merge with legacy required keys + for rk in pcls.required_config_keys(): + if rk not in required_keys: + required_keys.append(rk) except Exception: pass section = self.config_data.get("provider", {}).get(item_name, {})