Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/kind-geckos-search.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"comicarr": patch
---

Make blocked Usenet searches actionable with editable Newznab and SABnzbd settings, clearer route diagnostics, and credential-safe configuration updates.
12 changes: 6 additions & 6 deletions comicarr/app/config/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -369,22 +369,22 @@ def as_definition(self) -> tuple[type, str, Any]:
ConfigKey("MANUAL_PP_FOLDER", str, "PostProcess", None),
ConfigKey("PROVIDER_ORDER", str, "Providers", None),
ConfigKey("USENET_RETENTION", int, "Providers", 3500),
ConfigKey("NZB_DOWNLOADER", int, "Client", 3, readable=True),
ConfigKey("NZB_DOWNLOADER", int, "Client", 3, readable=True, writable=True),
ConfigKey("TORRENT_DOWNLOADER", int, "Client", 0, readable=True),
ConfigKey("SAB_HOST", str, "SABnzbd", None),
ConfigKey("SAB_HOST", str, "SABnzbd", None, readable=True, writable=True),
ConfigKey("SAB_USERNAME", str, "SABnzbd", None),
ConfigKey("SAB_PASSWORD", str, "SABnzbd", None),
ConfigKey("SAB_APIKEY", str, "SABnzbd", None),
ConfigKey("SAB_CATEGORY", str, "SABnzbd", None),
ConfigKey("SAB_APIKEY", str, "SABnzbd", None, writable=True),
ConfigKey("SAB_CATEGORY", str, "SABnzbd", None, readable=True, writable=True),
ConfigKey("SAB_PRIORITY", str, "SABnzbd", "Default"),
ConfigKey("SAB_DIRECT_UNPACK", bool, "SABnzbd", False),
ConfigKey("SAB_DIRECTORY", str, "SABnzbd", None),
ConfigKey("SAB_DIRECTORY", str, "SABnzbd", None, readable=True, writable=True),
ConfigKey("SAB_VERSION", str, "SABnzbd", None),
ConfigKey("SAB_MOVING_DELAY", int, "SABnzbd", 5),
ConfigKey("SAB_CLIENT_POST_PROCESSING", bool, "SABnzbd", False),
ConfigKey("SAB_REMOVE_COMPLETED", bool, "SABnzbd", False),
ConfigKey("SAB_REMOVE_FAILED", bool, "SABnzbd", False),
ConfigKey("SAB_VERIFY", bool, "SABnzbd", False),
ConfigKey("SAB_VERIFY", bool, "SABnzbd", False, readable=True, writable=True),
ConfigKey("NZBGET_HOST", str, "NZBGet", None),
ConfigKey("NZBGET_SUB", str, "NZBGet", None),
ConfigKey("NZBGET_PORT", str, "NZBGet", None),
Expand Down
32 changes: 30 additions & 2 deletions comicarr/app/search/health.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@
"path_not_ready",
"client_not_ready",
"unsupported_restart_correlation",
"provider_disabled",
"downloader_disabled",
"provider_not_configured",
"disabled",
)
)
Expand Down Expand Up @@ -273,6 +276,13 @@ def is_active_blocked(name):
)
if diagnostics:
names[route] = list(dict.fromkeys(names[route] + [item["name"] for item in diagnostics]))
configured_provider_count = len(diagnostics)
if route == "nzb":
configured_provider_count = int(bool(getattr(config, "EXPERIMENTAL", False))) + sum(
1
for row in (getattr(config, "EXTRA_NEWZNABS", None) or [])
if isinstance(row, (list, tuple)) and len(row) >= 6
)
enabled = _route_enabled(config, route)
client, client_ready, path_ready, restart_safe = _downstream_readiness(config, route)
downstream_ready = client_ready and path_ready
Expand All @@ -289,7 +299,25 @@ def is_active_blocked(name):
all_blocked = all_blocked or history_blocked
ready = bool(enabled and downstream_ready and restart_safe and not all_blocked and not maintenance_reason)
if not enabled:
reason = "disabled"
if route == "nzb":
raw_providers = list(getattr(config, "EXTRA_NEWZNABS", None) or [])
valid_providers = [row for row in raw_providers if isinstance(row, (list, tuple)) and len(row) >= 6]
enabled_providers = [
row for row in valid_providers if str(row[5]).lower() in {"1", "true", "yes", "on"}
]
downloader = int(getattr(config, "NZB_DOWNLOADER", 3) or 0)
if downloader == 3:
reason = "downloader_disabled"
elif not getattr(config, "EXPERIMENTAL", False) and not valid_providers:
reason = "provider_not_configured"
elif not getattr(config, "EXPERIMENTAL", False) and (
not getattr(config, "NEWZNAB", False) or not enabled_providers
):
reason = "provider_disabled"
else:
reason = "disabled"
else:
reason = "disabled"
elif maintenance_reason:
reason = str(maintenance_reason)
elif not restart_safe:
Expand Down Expand Up @@ -333,7 +361,7 @@ def is_active_blocked(name):
"last_success": last_success,
"last_failure": last_failure,
"last_error": _sanitize(history.get("last_error")),
"configured_provider_count": len(diagnostics),
"configured_provider_count": configured_provider_count,
"executable_provider_count": sum(1 for item in diagnostics if enabled and not item["blocked"]),
"attempted_provider_count": sum(1 for item in diagnostics if item["attempted"]),
"providers": diagnostics,
Expand Down
6 changes: 6 additions & 0 deletions comicarr/app/system/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,12 @@ async def update_providers(request: Request, ctx: AppContext = Depends(get_conte
return result


@router.get("/config/providers", dependencies=[Depends(require_session)])
def get_providers(ctx: AppContext = Depends(get_context)):
"""Return provider identities and enablement without credentials."""
return system_service.get_provider_config(ctx)


# ---------------------------------------------------------------------------
# Admin endpoints
# ---------------------------------------------------------------------------
Expand Down
150 changes: 148 additions & 2 deletions comicarr/app/system/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
import threading
from collections import namedtuple
from pathlib import Path
from urllib.parse import urlsplit, urlunsplit

from apscheduler.events import (
EVENT_JOB_ERROR,
Expand Down Expand Up @@ -273,6 +274,8 @@ def get_safe_config(ctx):
val = getattr(ctx.config, key, None)
if val is not None:
result[key] = val
if "SAB_HOST" in result:
result["SAB_HOST"] = _safe_provider_host(result["SAB_HOST"])

secret_indicators = {
"api_key_set": "API_KEY",
Expand All @@ -284,6 +287,7 @@ def get_safe_config(ctx):
"slack_webhook_url_set": "SLACK_WEBHOOK_URL",
"mattermost_webhook_url_set": "MATTERMOST_WEBHOOK_URL",
"discord_webhook_url_set": "DISCORD_WEBHOOK_URL",
"sab_apikey_set": "SAB_APIKEY",
}
for output_key, config_key in secret_indicators.items():
result[output_key] = _secret_is_configured(getattr(ctx.config, config_key, None))
Expand All @@ -304,9 +308,75 @@ def get_safe_config(ctx):
version = get_release_version()
if version:
result["version"] = version
result["newznab"] = _safe_provider_projection(ctx.config, "newznab")
result["torznab"] = _safe_provider_projection(ctx.config, "torznab")
return result


def _safe_provider_host(value):
"""Return a provider URL without userinfo credentials."""
host = str(value or "")
try:
parsed = urlsplit(host)
if parsed.username or parsed.password:
safe_netloc = parsed.netloc.rsplit("@", 1)[-1]
host = urlunsplit((parsed.scheme, safe_netloc, parsed.path, parsed.query, parsed.fragment))
except ValueError:
return ""
return redact_sensitive_text(host)


def _http_origin(value):
"""Return a normalized HTTP origin for binding a stored credential."""
try:
parsed = urlsplit(str(value or ""))
scheme = parsed.scheme.lower()
hostname = parsed.hostname.lower() if parsed.hostname else None
if scheme not in {"http", "https"} or not hostname:
return None
port = parsed.port
except (TypeError, ValueError):
return None
if port is None:
port = 443 if scheme == "https" else 80
return scheme, hostname, port


def _safe_provider_projection(config, provider_type):
"""Build the credential-free provider projection returned by the API."""
attr_name = "EXTRA_NEWZNABS" if provider_type == "newznab" else "EXTRA_TORZNABS"
enabled_key = "NEWZNAB" if provider_type == "newznab" else "ENABLE_TORZNAB"
rows = []
for entry in getattr(config, attr_name, None) or []:
if not isinstance(entry, (list, tuple)) or len(entry) < 6:
continue
row = {
"name": str(entry[0] or ""),
"host": _safe_provider_host(entry[1]),
"verify": str(entry[2]).lower() in {"1", "true", "yes", "on"},
"categories": str(entry[4] or "").replace("#", ","),
"enabled": str(entry[5]).lower() in {"1", "true", "yes", "on"},
"api_key_set": _secret_is_configured(entry[3]),
}
if len(entry) >= 7:
try:
row["id"] = int(entry[6])
except (TypeError, ValueError):
pass
rows.append(row)
return {"enabled": bool(getattr(config, enabled_key, False)), "providers": rows}


def get_provider_config(ctx):
"""Return credential-free Newznab and Torznab settings for the UI."""
if not ctx.config:
return {"newznab": {"enabled": False, "providers": []}, "torznab": {"enabled": False, "providers": []}}
return {
"newznab": _safe_provider_projection(ctx.config, "newznab"),
"torznab": _safe_provider_projection(ctx.config, "torznab"),
}


WRITABLE_CONFIG_KEYS = writable_keys()


Expand All @@ -330,6 +400,28 @@ def update_config(ctx, key_values):
if not filtered:
return {"success": False, "error": "No valid config keys provided"}

if "NZB_DOWNLOADER" in filtered:
nzb_downloader = filtered["NZB_DOWNLOADER"]
if isinstance(nzb_downloader, bool) or not isinstance(nzb_downloader, int) or not 0 <= nzb_downloader <= 3:
return {"success": False, "error": "NZB_DOWNLOADER must be an integer between 0 and 3"}

if "SAB_HOST" in filtered:
new_origin = _http_origin(filtered["SAB_HOST"])
if new_origin is None:
return {"success": False, "error": "SABnzbd server must be a valid HTTP or HTTPS URL"}
old_origin = _http_origin(getattr(ctx.config, "SAB_HOST", None))
stored_key = getattr(ctx.config, "SAB_APIKEY", None)
replacement_key = filtered.get("SAB_APIKEY")
if (
old_origin != new_origin
and _secret_is_configured(stored_key)
and not _secret_is_configured(replacement_key)
):
return {
"success": False,
"error": "SABnzbd API key is required when changing the server origin",
}

interval_changed = any(k in set(SCHEDULER_JOB_INTERVALS.values()) for k in filtered)

try:
Expand Down Expand Up @@ -385,27 +477,81 @@ def update_providers(ctx, provider_data):

provider_type = provider_data.get("type")
providers = provider_data.get("providers", [])
object_payload = any(isinstance(row, dict) for row in providers) if isinstance(providers, list) else False

if provider_type not in ("newznab", "torznab"):
return {"success": False, "error": "Invalid provider type"}
if not isinstance(providers, list):
return {"success": False, "error": "Invalid provider list"}
if "enabled" in provider_data and not isinstance(provider_data["enabled"], bool):
return {"success": False, "error": "Provider enabled must be a boolean"}

config_key = "EXTRA_NEWZNABS" if provider_type == "newznab" else "EXTRA_TORZNABS"
if object_payload:
existing = getattr(ctx.config, config_key, []) or []
by_id = {str(row[6]): row for row in existing if isinstance(row, (list, tuple)) and len(row) >= 7}
by_identity = {
(str(row[0]), _safe_provider_host(row[1])): row
for row in existing
if isinstance(row, (list, tuple)) and len(row) >= 6
}
normalized = []
for row in providers:
if not isinstance(row, dict):
return {"success": False, "error": "Invalid provider configuration"}
old = by_id.get(str(row.get("id"))) or by_identity.get(
(str(row.get("name") or ""), _safe_provider_host(row.get("host")))
)
credential = row.get("api_key", row.get("apikey"))
host = str(row.get("host") or "")
new_origin = _http_origin(host)
if new_origin is None:
return {"success": False, "error": "Provider URL must use HTTP or HTTPS"}
if credential in (None, "") and old is not None and _secret_is_configured(old[3]):
if _http_origin(old[1]) != new_origin:
return {
"success": False,
"error": "A new API key is required when changing a provider origin",
}
credential = old[3]
if old is not None and host == _safe_provider_host(old[1]):
host = old[1]
Comment thread
coderabbitai[bot] marked this conversation as resolved.
normalized_row = [
row.get("name", ""),
host,
"1" if row.get("verify") else "0",
credential or "",
str(row.get("categories") or "").replace(",", "#"),
"1" if row.get("enabled") else "0",
]
provider_id = (
row.get("id") if row.get("id") is not None else (old[6] if old is not None and len(old) >= 7 else None)
)
if provider_id is not None:
normalized_row.append(provider_id)
normalized.append(normalized_row)
providers = normalized
try:
ctx.config.validate_provider_extra_value(config_key, providers)
except (TypeError, ValueError):
return {"success": False, "error": "Invalid provider configuration"}
values = {config_key: providers}
if "enabled" in provider_data:
enabled_key = "NEWZNAB" if provider_type == "newznab" else "ENABLE_TORZNAB"
values[enabled_key] = provider_data["enabled"]
try:
persisted = ctx.config.apply_transaction({config_key: providers}, configure=False)
persisted = ctx.config.apply_transaction(values, configure=False)
except Exception as e:
logger.error("[PROVIDERS] Failed to persist provider configuration: %s" % type(e).__name__)
persisted = False

if persisted is False:
return {"success": False, "error": PROVIDER_CONFIG_PERSISTENCE_ERROR}

return {"success": True}
result = {"success": True}
if object_payload:
result.update(_safe_provider_projection(ctx.config, provider_type))
return result


# Scheduler job id -> the config attribute that drives its cadence, in minutes.
Expand Down
7 changes: 3 additions & 4 deletions comicarr/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -1210,12 +1210,11 @@ def apply_transaction(self, values, configure=True):
try:
provider_values = {key: value for key, value in values.items() if key in _PROVIDER_EXTRA_FIELDS}
scalar_values = {key: value for key, value in values.items() if key not in _PROVIDER_EXTRA_FIELDS}
if provider_values and scalar_values:
raise ValueError("Provider and scalar settings require separate transactions")
if provider_values:
# Provider values are fully normalized and published by
# _writeconfig; the broad legacy configure pass adds no
# provider state and cannot be rolled back safely.
# _writeconfig. Scalar values can share this durable write,
# but the broad legacy configure pass adds no provider state
# and cannot be rolled back safely.
configure = False
if scalar_values:
self.process_kwargs(scalar_values)
Expand Down
11 changes: 9 additions & 2 deletions comicarr/sabnzbd.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@

import comicarr
from comicarr import cdh_mapping, logger
from comicarr.app.common.redaction import redact_sensitive_text


class SABnzbd(object):
Expand Down Expand Up @@ -79,7 +80,10 @@ def sender(self, nzbpath=None, chkstatus=False):
timeout=30,
)
except Exception as e:
logger.warn("Failed to send to client. Error returned: %s" % e)
logger.warn(
"[SAB-SEND] Failed to send to client. Error returned: %s"
% redact_sensitive_text(e, secrets=(getattr(comicarr.CONFIG, "SAB_APIKEY", None),))
)
return {"status": False}
else:
sendresponse = sendit.json()
Expand Down Expand Up @@ -124,7 +128,10 @@ def processor(self):
time.sleep(5) # pause 5 seconds before monitoring just so it hits the queue
h = requests.get(self.sab_url, params=self.params["queue"], verify=comicarr.CONFIG.SAB_VERIFY, timeout=30)
except Exception as e:
logger.fdebug("uh-oh: %s" % e)
logger.fdebug(
"[SAB-QUEUE] uh-oh: %s"
% redact_sensitive_text(e, secrets=(getattr(comicarr.CONFIG, "SAB_APIKEY", None),))
)
return self.historycheck(self.params)
else:
queueresponse = h.json()
Expand Down
15 changes: 12 additions & 3 deletions comicarr/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -1340,17 +1340,26 @@ def NZB_SEARCH(
r = get_http_session().get(findurl, params=payload, verify=verify, headers=headers, timeout=30)
r.raise_for_status()
except requests.exceptions.Timeout as e:
logger.warn("Timeout occured fetching data from %s: %s" % (nzbprov, e))
logger.warn(
"[NZB-SEARCH] Timeout occured fetching data from %s: %s"
% (nzbprov, redact_sensitive_text(e, secrets=(apikey,)))
)
is_info["foundc"]["status"] = False
break
except requests.exceptions.ConnectionError as e:
logger.warn("Connection error trying to retrieve data from %s: %s" % (nzbprov, e))
logger.warn(
"[NZB-SEARCH] Connection error trying to retrieve data from %s: %s"
% (nzbprov, redact_sensitive_text(e, secrets=(apikey,)))
)
if helpers.provider_unreachable(e):
helpers.disable_provider(tmpprov, "Connection Refused.")
is_info["foundc"]["status"] = False
break
except requests.exceptions.RequestException as e:
logger.warn("General Error fetching data from %s: %s" % (nzbprov, e))
logger.warn(
"[NZB-SEARCH] General Error fetching data from %s: %s"
% (nzbprov, redact_sensitive_text(e, secrets=(apikey,)))
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if helpers.provider_unreachable(e):
helpers.disable_provider(tmpprov, "Connection Refused.")
logger.warn("Aborting search due to Provider unavailability")
Expand Down
Loading
Loading