Skip to content
Draft
Show file tree
Hide file tree
Changes from 5 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
105 changes: 105 additions & 0 deletions repo/plugin.video.nzbdav/resources/lib/filter.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"""Result filtering and sorting using PTT for title parsing."""

import math
import re
import time
from copy import deepcopy
from types import SimpleNamespace
Expand Down Expand Up @@ -514,6 +515,110 @@
return filtered, all_parsed


_CONTENT_EXTRA_MARKERS = re.compile(
r"\b(?:behind[\W_]+the[\W_]+scenes|making[\W_]+of|featurette|"
r"documentary|interview|trailer|sample|extras?)\b",
re.I,
)
_SEASON_EPISODE_RE = re.compile(
r"(?<![A-Za-z0-9])S(\d{1,2})[\W_]*E(\d{1,3})(?![A-Za-z0-9])",
re.I,
)
_YEAR_RE = re.compile(r"(?<!\d)((?:19|20)\d{2})(?!\d)")


def _content_phrase_pattern(title):
"""Return a boundary-safe release-title pattern for a requested title."""
words = re.findall(r"[A-Za-z0-9]+", str(title or "").lower())
words = [word for word in words if len(word) > 1 or word.isdigit()]
if not words:
return None
return re.compile(
r"(?<![A-Za-z0-9])"
+ r"[\W_]+".join(re.escape(word) for word in words)
+ r"(?![A-Za-z0-9])",
re.I,
)


def _episode_identity_matches(candidate_title, phrase_match, identity):

Check warning on line 544 in repo/plugin.video.nzbdav/resources/lib/filter.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

repo/plugin.video.nzbdav/resources/lib/filter.py#L544

Method _episode_identity_matches has a cyclomatic complexity of 9 (limit is 8)
"""Return whether an episode candidate has the requested identity."""
season = str(identity.get("season", "") or "")
episode = str(identity.get("episode", "") or "")
episode_match = _SEASON_EPISODE_RE.search(candidate_title)
try:
requested_pair = (int(season), int(episode))
except (TypeError, ValueError):
requested_pair = None
if requested_pair is not None and (
episode_match is None
or (int(episode_match.group(1)), int(episode_match.group(2))) != requested_pair
):
return False
# The requested show name must not occur only as another show's episode
# title (for example The.Rookie.S01E03.<requested title>).
return not (
phrase_match is not None
and episode_match is not None
and phrase_match.start() > episode_match.start()
)


def _media_type_matches(candidate_title, phrase_match, identity):
"""Return whether movie/episode markers agree with the request."""
content_type = str(identity.get("type", "") or "").lower()
season = str(identity.get("season", "") or "")
episode = str(identity.get("episode", "") or "")
if content_type == "episode" or (season and episode):
return _episode_identity_matches(candidate_title, phrase_match, identity)
return content_type != "movie" or _SEASON_EPISODE_RE.search(candidate_title) is None
Comment thread
coderabbitai[bot] marked this conversation as resolved.


def _year_matches(candidate_title, identity):
"""Return whether an explicit release year agrees with the request."""
requested_year = str(identity.get("year", "") or "")
candidate_years = set(_YEAR_RE.findall(candidate_title))
return not (
requested_year and candidate_years and requested_year not in candidate_years
)


def result_matches_requested_content(result, identity):
"""Fail closed on an obvious wrong-title, movie, or episode result."""
if not isinstance(result, dict):
return False
candidate_title = str(result.get("title", "") or "")
phrase = _content_phrase_pattern(identity.get("title", ""))
phrase_match = phrase.search(candidate_title) if phrase is not None else None
if phrase is not None and phrase_match is None:
return False

if not _media_type_matches(candidate_title, phrase_match, identity):
return False
if not _year_matches(candidate_title, identity):
return False
return not _CONTENT_EXTRA_MARKERS.search(candidate_title)


def filter_requested_content(results, identity):
"""Keep only rows that strictly match the requested content identity."""
rows = results or []
requested_identity = identity or {}
matched = [
result
for result in rows
if result_matches_requested_content(result, requested_identity)
]
rejected = len(rows) - len(matched)
if rejected:
xbmc.log(
"NZB-DAV: Strict identity filter rejected {} of {} candidates for "
"'{}'".format(rejected, len(rows), requested_identity.get("title", "")),
xbmc.LOGINFO,
)
return matched


def _pubdate_sort_key(result):
"""Return a sortable datetime-derived key for RFC-822 pubdate.

Expand Down
39 changes: 21 additions & 18 deletions repo/plugin.video.nzbdav/resources/lib/http_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,16 @@
re.IGNORECASE,
)

# Some Newznab indexers return download URLs shaped like
# ``/getnzb/id.nzb&i=ACCOUNT&r=APIKEY``. Because there is no ``?``, both
# credentials are parsed as part of the URL path rather than query params.
# Restrict the short ``i``/``r`` names to getnzb-looking paths so ordinary
# application URLs using those names are not over-redacted.
_GETNZB_PATH_CRED_RE = re.compile(
r"([&;](?:i|r))=([^&\s\"'<>]+)",
re.IGNORECASE,
)

Comment on lines +44 to +53

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n "_REDACT_PARAM_NAMES" repo/plugin.video.nzbdav/resources/lib/http_util.py -B2 -A10

Repository: Appz4Fun/nzbdavkodi

Length of output: 1244


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '1,130p' repo/plugin.video.nzbdav/resources/lib/http_util.py | cat -n
printf '\n--- occurrences ---\n'
rg -n "_GETNZB_PATH_CRED_RE|redact_url|_REDACT_PARAM_NAMES|getnzb" repo/plugin.video.nzbdav/resources/lib
printf '\n--- read-only parser probe ---\n'
python3 - <<'PY'
import urllib.parse as up
url = "https://example.com/getnzb/id.nzb?i=ACCOUNT&r=APIKEY"
parts = up.urlparse(url)
print("scheme", parts.scheme)
print("netloc", parts.netloc)
print("path", parts.path)
print("query", parts.query)
print("parsed qsl keys:", [k for k, v in up.parse_qsl(parts.query, keep_blank_values=True)])
PY

Repository: Appz4Fun/nzbdavkodi

Length of output: 11415


Redact i/r when they appear as getnzb query parameters.

redact_url() only applies _GETNZB_PATH_CRED_RE to parts.path; a ?-delimited getnzb URL such as .../getnzb/id.nzb?i=ACCOUNT&r=APIKEY keeps those values in parts.query, and i/r are not included in _REDACT_PARAM_NAMES, so they would round-trip unredacted. Handle the same scoped getnzb/.nzb& case for query params as well.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@repo/plugin.video.nzbdav/resources/lib/http_util.py` around lines 44 - 53,
Update redact_url() to apply the scoped getnzb credential redaction to query
parameters as well as parts.path, covering getnzb URLs with ?i=...&r=... and the
existing .nzb& form. Keep i/r redaction limited to getnzb-looking URLs, while
preserving ordinary application query parameters and existing
_REDACT_PARAM_NAMES behavior.

# Catch ``scheme://user:password@host`` userinfo embedded in free-form text.
# urllib / socket / xbmcvfs errors sometimes echo the failing URL — e.g. the
# NZBGet JSON-RPC URL or the ``smb://user:pass@host/...`` completed-folder
Expand Down Expand Up @@ -91,9 +101,10 @@ def redact_url(url):
# WebDAV stack used to accept). Strip the password half before
# logging. TODO.md §H.2-H2d.
netloc = _redact_netloc_userinfo(parts.netloc)
return urlunsplit(
(parts.scheme, netloc, parts.path, urlencode(query), parts.fragment)
)
path = parts.path
if "getnzb" in path.lower() or ".nzb&" in path.lower():
path = _GETNZB_PATH_CRED_RE.sub(r"\1=REDACTED", path)
return urlunsplit((parts.scheme, netloc, path, urlencode(query), parts.fragment))


def _redact_netloc_userinfo(netloc):
Expand All @@ -113,22 +124,14 @@ def _redact_netloc_userinfo(netloc):
return "{}@{}".format(userinfo, host)


def _redact_url_userinfo_span(match):
"""Strip the password from a URL span's ``user:pass@host`` userinfo.
def _redact_url_span(match):
"""Redact credentials only within a matched URL span.

Reuses the same ``rpartition('@')`` / ``partition(':')`` logic as
``redact_url`` so an ``@`` *inside* the password (or an empty username)
can't leak. Spans with no userinfo round-trip unchanged.
Reusing ``redact_url`` covers both URL userinfo and non-standard Newznab
path credentials without applying short ``i``/``r`` parameter names to
unrelated text elsewhere in the same message.
"""
span = match.group(0)
try:
parts = urlsplit(span)
except (ValueError, TypeError):
return span
if not parts.netloc or "@" not in parts.netloc:
return span
netloc = _redact_netloc_userinfo(parts.netloc)
return urlunsplit((parts.scheme, netloc, parts.path, parts.query, parts.fragment))
return redact_url(match.group(0))


def redact_text(text):
Expand All @@ -146,7 +149,7 @@ def redact_text(text):
# ``\1`` is the key group; a backreference replacement avoids a per-match
# Python callback on this hot logging/error path.
redacted = _EMBEDDED_CRED_RE.sub(r"\1=REDACTED", str(text))
return _EMBEDDED_URL_RE.sub(_redact_url_userinfo_span, redacted)
return _EMBEDDED_URL_RE.sub(_redact_url_span, redacted)


_WHITESPACE_RE = re.compile(r"\s+")
Expand Down
8 changes: 8 additions & 0 deletions repo/plugin.video.nzbdav/resources/lib/resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,8 @@

_POLL_NEAR_COMPLETE_FAST_REPOLL_COUNT = 5

_POLL_OBSERVABILITY_TIMEOUT_SECONDS = 60

_PLAYBACK_CLEANUP_HANDOFF_GRACE_SECONDS = 0.25

_PLAYBACK_PREPARE_HANDOFF_GRACE_SECONDS = 8.0
Expand Down Expand Up @@ -439,9 +441,11 @@
_mark_dead_on_failed_history,
_mark_dead_on_terminal_job_status,
_notify_primary_submitted,
_poll_observation_unavailable,
_poll_until_ready,
_record_download_soft,
_submit_and_announce,
_surface_poll_observation_timeout,
_wait_between_polls,
)
from resources.lib.resolver_prepare import ( # noqa: E402,F401
Expand Down Expand Up @@ -487,6 +491,10 @@
_set_playback_monitor_properties,
_show_cache_prompt_after_playback,
)
from resources.lib.resolver_retry import ( # noqa: E402,F401
_poll_with_release_retries,
_retry_attempts,
)
from resources.lib.resolver_submit import ( # noqa: E402,F401
_adopt_queued_or_completed_job,
_await_adoptable_probe_result,
Expand Down
15 changes: 13 additions & 2 deletions repo/plugin.video.nzbdav/resources/lib/resolver_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,13 +198,24 @@ def _invoke_poll_until_ready(
download_pubdate=params_src.get("_download_pubdate"),
download_size=params_src.get("_download_size"),
)
return _resolver._poll_until_ready(
retry_candidates = params_src.get("_retry_candidates", [])
if not retry_candidates:
return _resolver._poll_until_ready(
nzb_url,
title,
dialog,
poll_interval,
download_timeout,
poll_ctx=poll_ctx,
)
return _resolver._poll_with_release_retries(
nzb_url,
title,
retry_candidates,
dialog,
poll_interval,
download_timeout,
poll_ctx=poll_ctx,
poll_ctx,
)


Expand Down
82 changes: 70 additions & 12 deletions repo/plugin.video.nzbdav/resources/lib/resolver_pollloop.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,59 @@ def _wait_between_polls(monitor, wait_seconds, nzo_id, settings_getter):
return _resolver._POLL_CONTINUE


def _poll_observation_unavailable(job_status, history, webdav_error):
"""Return whether every backend observation failed for this poll."""
return (
job_status is None
and history is None
and webdav_error in ("server_error", "connection_error")
)


def _surface_poll_observation_timeout(nzo_id, settings_getter):
"""Surface a bounded backend-observation failure without cancelling the job."""
message = _resolver._string(_resolver._ERROR_MESSAGES["connection_error"])
_resolver.xbmc.log(
"NZB-DAV: Backend state remained unavailable for {}s; "
"stopping local poll for nzo_id={} without cancelling the remote "
"job".format(_resolver._POLL_OBSERVABILITY_TIMEOUT_SECONDS, nzo_id),
_resolver.xbmc.LOGERROR,
)
if settings_getter is None:
_resolver.xbmcgui.Dialog().ok(_resolver._addon_name(), message)
else:
_resolver._notify(_resolver._addon_name(), message, 5000)


def _advance_observation_outage(
started, elapsed, job_status, history, webdav_error, nzo_id, settings_getter
):
"""Advance or reset the bounded total-observation outage."""
if not _poll_observation_unavailable(job_status, history, webdav_error):
return -1.0, False
if started < 0:
started = elapsed
if elapsed - started < _resolver._POLL_OBSERVABILITY_TIMEOUT_SECONDS:
return started, False
_surface_poll_observation_timeout(nzo_id, settings_getter)
return started, True


def _poll_history_kwargs(poll_ctx, monitor):
"""Build history-resolution options for one poll."""
kwargs = {
"monitor": monitor,
"settings_getter": poll_ctx.settings_getter,
"modal_failures": poll_ctx.settings_getter is None,
"download_size": poll_ctx.download_size,
}
if poll_ctx.episode_context is not None:
kwargs["episode_context"] = poll_ctx.episode_context
elif poll_ctx.requested_episode is not None:
kwargs["requested_episode"] = poll_ctx.requested_episode
return kwargs


def _notify_primary_submitted(on_primary_submitted, nzo_id):
"""Fire the primary-submitted callback, never letting it break the poll loop."""
if on_primary_submitted is None:
Expand Down Expand Up @@ -185,18 +238,20 @@ def _poll_until_ready(
no_video_retries = 0
max_no_video_retries = 5
near_complete_fast_repolls = 0
observation_error_started = -1.0

def _mark_dead(nzo):
if poll_ctx.dead is not None:
poll_ctx.dead.add(nzb_url=nzb_url, nzo_id=nzo)

def _run_one_poll():
def _run_one_poll(elapsed):
"""Run one poll iteration.

Returns the ``(stream_url, stream_headers)`` tuple to return from
``_poll_until_ready``, or ``_POLL_CONTINUE`` to keep looping.
"""
nonlocal last_status, no_video_retries, near_complete_fast_repolls
nonlocal observation_error_started
job_status, history, webdav_error = _resolver._poll_once(
nzo_id,
title,
Expand All @@ -213,16 +268,7 @@ def _run_one_poll():
_mark_dead_on_terminal_job_status(job_status, nzo_id, _mark_dead)
return None, None

history_kwargs = {
"monitor": monitor,
"settings_getter": poll_ctx.settings_getter,
"modal_failures": poll_ctx.settings_getter is None,
"download_size": poll_ctx.download_size,
}
if poll_ctx.episode_context is not None:
history_kwargs["episode_context"] = poll_ctx.episode_context
elif poll_ctx.requested_episode is not None:
history_kwargs["requested_episode"] = poll_ctx.requested_episode
history_kwargs = _poll_history_kwargs(poll_ctx, monitor)
should_stop, stream_url, stream_headers, no_video_retries = (
_resolver._handle_history_result(
history, title, no_video_retries, max_no_video_retries, **history_kwargs
Expand All @@ -237,6 +283,18 @@ def _run_one_poll():
_mark_dead_on_failed_history(history, nzo_id, _mark_dead)
return None, None

observation_error_started, observation_timed_out = _advance_observation_outage(
observation_error_started,
elapsed,
job_status,
history,
webdav_error,
nzo_id,
poll_ctx.settings_getter,
)
if observation_timed_out:
return None, None

if _resolver._handle_webdav_error(nzo_id, webdav_error):
# Deliberately NOT calling cancel_job here. The WebDAV auth
# failure is an addon-side observation problem (the addon
Expand All @@ -260,6 +318,6 @@ def _run_one_poll():
iteration, elapsed, download_timeout, dialog, nzo_id, title
):
return None, None
result = _run_one_poll()
result = _run_one_poll(elapsed)
if result is not _resolver._POLL_CONTINUE:
return result
Loading