Skip to content
Draft
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
113 changes: 113 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,118 @@ def filter_results(results, settings_getter=None):
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 _requested_episode_pair(identity):
"""Return the requested numeric episode pair when both fields are valid."""
season = str(identity.get("season", "") or "")
episode = str(identity.get("episode", "") or "")
try:
return int(season), int(episode)
except (TypeError, ValueError):
return None


def _candidate_episode_pair(episode_match):
"""Return the release's numeric episode pair when it has one."""
if episode_match is None:
return None
return int(episode_match.group(1)), int(episode_match.group(2))


def _episode_identity_matches(candidate_title, phrase_match, identity):
"""Return whether an episode candidate has the requested identity."""
episode_match = _SEASON_EPISODE_RE.search(candidate_title)
requested_pair = _requested_episode_pair(identity)
actual_pair = _candidate_episode_pair(episode_match)
if requested_pair is not None and actual_pair != 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>).
if phrase_match is None or episode_match is None:
return True
return 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
5 changes: 3 additions & 2 deletions repo/plugin.video.nzbdav/resources/lib/player_installer.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ def _player_path_for(addon_id):
# Bump this when PLAYER_JSON's shape changes in a way that requires the
# installer to overwrite an older generation. We ignore the user's manual
# edits only when the stored schema_version differs from ours.
_PLAYER_SCHEMA_VERSION = 7
_PLAYER_SCHEMA_VERSION = 8

PLAYER_JSON = {
"name": "NZB-DAV",
Expand All @@ -49,7 +49,8 @@ def _player_path_for(addon_id):
"executebuiltin://RunScript("
"special://home/addons/plugin.video.nzbdav/addon.py,tmdb_play,"
"type=episode,title={showname_url},year={showyear},season={season},"
"episode={episode},imdb={imdb},tmdb_id={tmdb_id},tvdb={tvdb},"
"episode={episode},imdb={imdb},tmdb_id={tmdb},"
"show_tmdb_id={tmdb},episode_tmdb_id={eptmdb},tvdb={tvdb},"
"ep_season={ep_showseason},ep_episode={ep_showepisode})"
),
}
Expand Down
15 changes: 15 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 @@ -375,6 +377,13 @@
_status_dialog_message,
_stub_min_size_floor,
)
from resources.lib.resolver_metadata import ( # noqa: E402,F401
_apply_playback_identity,
_episode_identity,
_fallback_info,
_stable_unique_ids,
_tmdb_helper_metadata,
)
from resources.lib.resolver_playback import ( # noqa: E402,F401
_add_own_plugin_target_ids,
_add_request_headers,
Expand Down Expand Up @@ -439,9 +448,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 +498,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
26 changes: 22 additions & 4 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 Expand Up @@ -332,7 +343,11 @@ def _resolve_play_ready_stream(
return dialog
_resolver._arm_live_fallback_push(prepared, fallback_state, stream_url, dead=dead)
_resolver._finish_direct_playback(
handle, prepared, resume_key=release_id, resume_seconds=chosen
handle,
prepared,
resume_key=release_id,
resume_seconds=chosen,
params=params,
)
# Playback handed off to Kodi: start the fallback worker's "minutes
# into playback" countdown now, not from the earlier primary submit.
Expand Down Expand Up @@ -502,7 +517,10 @@ def _resolve_and_play_ready_stream(
return dialog
_resolver._arm_live_fallback_push(prepared, fallback_state, stream_url, dead=dead)
_resolver._finish_player_playback(
prepared, resume_key=release_id, resume_seconds=chosen
prepared,
resume_key=release_id,
resume_seconds=chosen,
params=resume_params,
)
# Playback handed off to the player: start the fallback worker's
# "minutes into playback" countdown now, not from the primary submit.
Expand Down
Loading