This commit is contained in:
2026-01-11 03:24:49 -08:00
parent e608b88062
commit 5985a8306a
13 changed files with 401 additions and 101 deletions

View File

@@ -568,6 +568,18 @@ class AllDebrid(TableProviderMixin, Provider):
URL = ("magnet:",) URL = ("magnet:",)
URL_DOMAINS = () URL_DOMAINS = ()
@classmethod
def config(cls) -> List[Dict[str, Any]]:
return [
{
"key": "api_key",
"label": "API Key",
"default": "",
"required": True,
"secret": True
}
]
@staticmethod @staticmethod
def _resolve_magnet_spec_from_result(result: Any) -> Optional[str]: def _resolve_magnet_spec_from_result(result: Any) -> Optional[str]:
table = getattr(result, "table", None) table = getattr(result, "table", None)

View File

@@ -50,6 +50,33 @@ def _extract_key(payload: Any) -> Optional[str]:
class FileIO(Provider): class FileIO(Provider):
"""File provider for file.io.""" """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): def __init__(self, config: Optional[Dict[str, Any]] = None):
super().__init__(config) super().__init__(config)

View File

@@ -464,16 +464,36 @@ class InternetArchive(Provider):
- search-file -provider internetarchive <query> - search-file -provider internetarchive <query>
- download-file / provider.download() from search results - download-file / provider.download() from search results
- add-file -provider internetarchive (uploads) - 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",) 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 = { TABLE_AUTO_STAGES = {
"internetarchive": ["download-file"], "internetarchive": ["download-file"],
"internetarchive.folder": ["download-file"], "internetarchive.folder": ["download-file"],

View File

@@ -234,6 +234,29 @@ class Matrix(TableProviderMixin, Provider):
4. Selection triggers upload of pending files to selected rooms 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): def __init__(self, config: Optional[Dict[str, Any]] = None):
super().__init__(config) super().__init__(config)
self._init_ok: Optional[bool] = None self._init_ok: Optional[bool] = None

View File

@@ -28,6 +28,13 @@ from Provider.metadata_provider import (
from SYS.utils import unique_path from SYS.utils import unique_path
_ARCHIVE_VERIFY_VALUE = get_requests_verify_value() _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: def _create_archive_session() -> requests.Session:
session = requests.Session() session = requests.Session()
@@ -279,17 +286,30 @@ class OpenLibrary(Provider):
"openlibrary": ["download-file"], "openlibrary": ["download-file"],
} }
REQUIRED_CONFIG_KEYS = ( @classmethod
"email", def config(cls) -> List[Dict[str, Any]]:
"password", return [
) {
"key": "email",
DEFAULT_ARCHIVE_SCALE = 4 "label": "Archive.org Email",
QUALITY_TO_ARCHIVE_SCALE = { "default": "",
"high": 2, "required": True
"medium": 5, },
"low": 8, {
"key": "password",
"label": "Archive.org Password",
"default": "",
"required": True,
"secret": True
},
{
"key": "quality",
"label": "Image Quality",
"default": "medium",
"placeholder": "high, medium, low"
} }
]
# Domains that should be routed to this provider when the user supplies a URL. # Domains that should be routed to this provider when the user supplies a URL.
# (Used by ProviderCore.registry.match_provider_name_for_url) # (Used by ProviderCore.registry.match_provider_name_for_url)
URL_DOMAINS = ( URL_DOMAINS = (
@@ -342,88 +362,53 @@ class OpenLibrary(Provider):
} }
@staticmethod @staticmethod
def _credential_archive(config: Dict[str, def _credential_archive(config: Dict[str, Any]) -> Tuple[Optional[str], Optional[str]]:
Any]) -> Tuple[Optional[str], """Get Archive.org email/password from config."""
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": "..."}
"""
if not isinstance(config, dict): if not isinstance(config, dict):
return None, None return None, None
provider_config = config.get("provider", entry = config.get("provider", {}).get("openlibrary", {})
{}) if isinstance(entry, dict):
if isinstance(provider_config, dict): email = entry.get("email")
openlibrary_config = provider_config.get("openlibrary", password = entry.get("password")
{})
if isinstance(openlibrary_config, dict):
email = openlibrary_config.get("email")
password = openlibrary_config.get("password")
if email or password: if email or password:
return str(email) if email is not None else None, ( return str(email) if email is not None else None, (
str(password) if password is not None else None str(password) if password is not None else None
) )
archive_config = config.get("Archive") return None, None
if isinstance(archive_config, dict):
email = archive_config.get("email")
password = archive_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
)
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
)
@classmethod @classmethod
def _archive_scale_from_config(cls, config: Dict[str, Any]) -> int: def _archive_scale_from_config(cls, config: Dict[str, Any]) -> int:
"""Resolve Archive.org book-reader scale from provider config. """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)
if not isinstance(config, dict): if not isinstance(config, dict):
return default_scale return _DEFAULT_ARCHIVE_SCALE
provider_config = config.get("provider", {}) entry = config.get("provider", {}).get("openlibrary", {})
openlibrary_config = None if not isinstance(entry, dict):
if isinstance(provider_config, dict): return _DEFAULT_ARCHIVE_SCALE
openlibrary_config = provider_config.get("openlibrary")
if not isinstance(openlibrary_config, dict):
openlibrary_config = {}
raw_quality = openlibrary_config.get("quality") raw_quality = entry.get("quality")
if raw_quality is None: if raw_quality is None:
return default_scale return _DEFAULT_ARCHIVE_SCALE
if isinstance(raw_quality, (int, float)): if isinstance(raw_quality, (int, float)):
try:
val = int(raw_quality) val = int(raw_quality)
except Exception: return val if val > 0 else _DEFAULT_ARCHIVE_SCALE
return default_scale
return val if val > 0 else default_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: try:
q = str(raw_quality).strip().lower() val = int(q)
return val if val > 0 else _DEFAULT_ARCHIVE_SCALE
except Exception: except Exception:
return default_scale return _DEFAULT_ARCHIVE_SCALE
if not q:
return default_scale
mapped = cls.QUALITY_TO_ARCHIVE_SCALE.get(q)
if isinstance(mapped, int) and mapped > 0: if isinstance(mapped, int) and mapped > 0:
return mapped return mapped

View File

@@ -210,6 +210,24 @@ class Soulseek(Provider):
} }
"""Search provider for Soulseek P2P network.""" """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 = { MUSIC_EXTENSIONS = {
".flac", ".flac",
".mp3", ".mp3",

View File

@@ -149,6 +149,30 @@ class Telegram(Provider):
""" """
URL = ("t.me", "telegram.me") 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): def __init__(self, config: Optional[Dict[str, Any]] = None):
super().__init__(config) super().__init__(config)
telegram_conf = ( telegram_conf = (

View File

@@ -142,6 +142,21 @@ class Provider(ABC):
self.config = config or {} self.config = config or {}
self.name = self.__class__.__name__.lower() 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 @classmethod
def required_config_keys(cls) -> List[str]: def required_config_keys(cls) -> List[str]:
keys = getattr(cls, "REQUIRED_CONFIG_KEYS", None) keys = getattr(cls, "REQUIRED_CONFIG_KEYS", None)

View File

@@ -42,10 +42,22 @@ class Folder(Store):
Dict[str, Dict[str,
int]]] = {} int]]] = {}
def __new__(cls, *args: Any, **kwargs: Any) -> "Folder": @classmethod
return super().__new__(cls) def config(cls) -> List[Dict[str, Any]]:
return [
setattr(__new__, "keys", ("NAME", "PATH")) {
"key": "NAME",
"label": "Store Name",
"default": "",
"required": True
},
{
"key": "PATH",
"label": "Folder Path",
"default": "",
"required": True
}
]
def __init__( def __init__(
self, self,

View File

@@ -29,6 +29,32 @@ class HydrusNetwork(Store):
Maintains its own HydrusClient. 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: def _log_prefix(self) -> str:
store_name = getattr(self, "NAME", None) or "unknown" store_name = getattr(self, "NAME", None) or "unknown"
return f"[hydrusnetwork:{store_name}]" return f"[hydrusnetwork:{store_name}]"
@@ -46,8 +72,6 @@ class HydrusNetwork(Store):
setattr(instance, "URL", str(url)) setattr(instance, "URL", str(url))
return instance return instance
setattr(__new__, "keys", ("NAME", "API", "URL"))
def __init__( def __init__(
self, self,
instance_name: Optional[str] = None, instance_name: Optional[str] = None,

View File

@@ -12,6 +12,20 @@ from typing import Any, Dict, List, Optional, Tuple
class Store(ABC): 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 @abstractmethod
def add_file(self, file_path: Path, **kwargs: Any) -> str: def add_file(self, file_path: Path, **kwargs: Any) -> str:
raise NotImplementedError raise NotImplementedError

View File

@@ -80,6 +80,23 @@ def _discover_store_classes() -> Dict[str, Type[BaseStore]]:
def _required_keys_for(store_cls: Type[BaseStore]) -> list[str]: 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) keys = getattr(store_cls.__new__, "keys", None)
if keys is None: if keys is None:
return [] return []

View File

@@ -209,15 +209,41 @@ class ConfigModal(ModalScreen):
item_type = str(self.editing_item_type or "") item_type = str(self.editing_item_type or "")
item_name = str(self.editing_item_name or "") item_name = str(self.editing_item_name or "")
provider_schema_map = {}
# Parse item_type for store-{stype} or just provider # Parse item_type for store-{stype} or just provider
if item_type.startswith("store-"): if item_type.startswith("store-"):
stype = item_type.replace("store-", "") stype = item_type.replace("store-", "")
container.mount(Label(f"Editing Store: {item_name} ({stype})", classes="config-label")) container.mount(Label(f"Editing Store: {item_name} ({stype})", classes="config-label"))
section = self.config_data.get("store", {}).get(stype, {}).get(item_name, {}) 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: else:
container.mount(Label(f"Editing {item_type.capitalize()}: {item_name}", classes="config-label")) container.mount(Label(f"Editing {item_type.capitalize()}: {item_name}", classes="config-label"))
section = self.config_data.get(item_type, {}).get(item_name, {}) 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 # Show all existing keys
existing_keys_upper = set() existing_keys_upper = set()
for k, v in section.items(): for k, v in section.items():
@@ -229,10 +255,43 @@ class ConfigModal(ModalScreen):
continue continue
existing_keys_upper.add(k_upper) existing_keys_upper.add(k_upper)
container.mount(Label(k)) # Determine display props from schema
container.mount(Input(value=str(v), id=f"item-{k}", classes="config-input")) 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
# If it's a store, we might have required keys 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)
# 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-"): if item_type.startswith("store-"):
stype = item_type.replace("store-", "") stype = item_type.replace("store-", "")
classes = _discover_store_classes() classes = _discover_store_classes()
@@ -244,8 +303,9 @@ class ConfigModal(ModalScreen):
container.mount(Label(rk)) container.mount(Label(rk))
container.mount(Input(value="", id=f"item-{rk}", classes="config-input")) 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": if item_type == "provider":
# 2. Legacy required_config_keys
from ProviderCore.registry import get_provider_class from ProviderCore.registry import get_provider_class
try: try:
pcls = get_provider_class(item_name) pcls = get_provider_class(item_name)
@@ -307,10 +367,28 @@ class ConfigModal(ModalScreen):
self.editing_item_type = "provider" self.editing_item_type = "provider"
self.refresh_view() self.refresh_view()
elif bid == "add-store-btn": 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) self.app.push_screen(SelectionModal("Select Store Type", options), callback=self.on_store_type_selected)
elif bid == "add-provider-btn": 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) self.app.push_screen(SelectionModal("Select Provider Type", options), callback=self.on_provider_type_selected)
elif bid.startswith("del-store-"): elif bid.startswith("del-store-"):
parts = bid.replace("del-store-", "").split("-", 1) parts = bid.replace("del-store-", "").split("-", 1)
@@ -338,7 +416,20 @@ class ConfigModal(ModalScreen):
new_config = {"NAME": new_name} new_config = {"NAME": new_name}
classes = _discover_store_classes() classes = _discover_store_classes()
if stype in classes: if stype in classes:
required = _required_keys_for(classes[stype]) 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: for rk in required:
if rk.upper() != "NAME": if rk.upper() != "NAME":
new_config[rk] = "" new_config[rk] = ""
@@ -355,12 +446,19 @@ class ConfigModal(ModalScreen):
# For providers, they are usually top-level entries in 'provider' dict # For providers, they are usually top-level entries in 'provider' dict
if ptype not in self.config_data["provider"]: if ptype not in self.config_data["provider"]:
# Get required keys if possible
from ProviderCore.registry import get_provider_class from ProviderCore.registry import get_provider_class
try: try:
pcls = get_provider_class(ptype) pcls = get_provider_class(ptype)
new_config = {} new_config = {}
if pcls: if pcls:
# 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() required = pcls.required_config_keys()
for rk in required: for rk in required:
new_config[rk] = "" new_config[rk] = ""
@@ -420,14 +518,25 @@ class ConfigModal(ModalScreen):
stype = item_type.replace("store-", "") stype = item_type.replace("store-", "")
classes = _discover_store_classes() classes = _discover_store_classes()
if stype in 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, {}) section = self.config_data.get("store", {}).get(stype, {}).get(item_name, {})
elif item_type == "provider": elif item_type == "provider":
from ProviderCore.registry import get_provider_class from ProviderCore.registry import get_provider_class
try: try:
pcls = get_provider_class(item_name) pcls = get_provider_class(item_name)
if pcls: 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: except Exception:
pass pass
section = self.config_data.get("provider", {}).get(item_name, {}) section = self.config_data.get("provider", {}).get(item_name, {})