feat: add configurable mature blur threshold setting

Add new setting 'mature_blur_level' with options PG13/R/X/XXX to control
which NSFW rating level triggers blur filtering when NSFW blur is enabled.

- Backend: update preview selection logic to respect threshold
- Frontend: update UI components to use configurable threshold
- Settings: add validation and normalization for mature_blur_level
- Tests: add coverage for new threshold behavior
- Translations: add keys for all supported languages

Fixes #867
This commit is contained in:
Will Miao
2026-03-26 18:24:47 +08:00
parent 3b001a6cd8
commit ceeab0c998
28 changed files with 320 additions and 59 deletions

View File

@@ -16,7 +16,7 @@ from ..utils.constants import (
VALID_LORA_TYPES,
)
from ..utils.civitai_utils import rewrite_preview_url
from ..utils.preview_selection import select_preview_media
from ..utils.preview_selection import resolve_mature_threshold, select_preview_media
from ..utils.utils import sanitize_folder_name
from ..utils.exif_utils import ExifUtils
from ..utils.metadata_manager import MetadataManager
@@ -846,9 +846,13 @@ class DownloadManager:
blur_mature_content = bool(
settings_manager.get("blur_mature_content", True)
)
mature_threshold = resolve_mature_threshold(
{"mature_blur_level": settings_manager.get("mature_blur_level", "R")}
)
selected_image, nsfw_level = select_preview_media(
images,
blur_mature_content=blur_mature_content,
mature_threshold=mature_threshold,
)
preview_url = selected_image.get("url") if selected_image else None

View File

@@ -13,7 +13,7 @@ from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence
from .errors import RateLimitError, ResourceNotFoundError
from .settings_manager import get_settings_manager
from ..utils.civitai_utils import rewrite_preview_url
from ..utils.preview_selection import select_preview_media
from ..utils.preview_selection import resolve_mature_threshold, select_preview_media
logger = logging.getLogger(__name__)
@@ -1252,14 +1252,23 @@ class ModelUpdateService:
return None
blur_mature_content = True
mature_threshold = resolve_mature_threshold({"mature_blur_level": "R"})
settings = getattr(self, "_settings", None)
if settings is not None and hasattr(settings, "get"):
try:
blur_mature_content = bool(settings.get("blur_mature_content", True))
mature_threshold = resolve_mature_threshold(
{"mature_blur_level": settings.get("mature_blur_level", "R")}
)
except Exception: # pragma: no cover - defensive guard
blur_mature_content = True
mature_threshold = resolve_mature_threshold({"mature_blur_level": "R"})
selected, _ = select_preview_media(candidates, blur_mature_content=blur_mature_content)
selected, _ = select_preview_media(
candidates,
blur_mature_content=blur_mature_content,
mature_threshold=mature_threshold,
)
if not selected:
return None

View File

@@ -9,7 +9,7 @@ from urllib.parse import urlparse
from ..utils.constants import CARD_PREVIEW_WIDTH, PREVIEW_EXTENSIONS
from ..utils.civitai_utils import rewrite_preview_url
from ..utils.preview_selection import select_preview_media
from ..utils.preview_selection import resolve_mature_threshold, select_preview_media
from .settings_manager import get_settings_manager
logger = logging.getLogger(__name__)
@@ -49,9 +49,13 @@ class PreviewAssetService:
blur_mature_content = bool(
settings_manager.get("blur_mature_content", True)
)
mature_threshold = resolve_mature_threshold(
{"mature_blur_level": settings_manager.get("mature_blur_level", "R")}
)
first_preview, nsfw_level = select_preview_media(
images,
blur_mature_content=blur_mature_content,
mature_threshold=mature_threshold,
)
if not first_preview:
@@ -216,4 +220,3 @@ class PreviewAssetService:
if "webm" in content_type:
return ".webm"
return ".mp4"

View File

@@ -12,6 +12,7 @@ from typing import Any, Awaitable, Dict, Iterable, List, Mapping, Optional, Sequ
from platformdirs import user_config_dir
from ..utils.constants import DEFAULT_HASH_CHUNK_SIZE_MB, DEFAULT_PRIORITY_TAG_CONFIG
from ..utils.preview_selection import VALID_MATURE_BLUR_LEVELS
from ..utils.settings_paths import APP_NAME, ensure_settings_file, get_legacy_settings_path
from ..utils.tag_priorities import (
PriorityTagEntry,
@@ -59,6 +60,7 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
"optimize_example_images": True,
"auto_download_example_images": False,
"blur_mature_content": True,
"mature_blur_level": "R",
"autoplay_on_hover": False,
"display_density": "default",
"card_info_display": "always",
@@ -274,6 +276,16 @@ class SettingsManager:
self.settings["metadata_refresh_skip_paths"] = []
inserted_defaults = True
had_mature_level = "mature_blur_level" in self.settings
raw_mature_level = self.settings.get("mature_blur_level")
normalized_mature_level = self.normalize_mature_blur_level(raw_mature_level)
if normalized_mature_level != raw_mature_level:
self.settings["mature_blur_level"] = normalized_mature_level
if had_mature_level:
updated_existing = True
else:
inserted_defaults = True
for key, value in defaults.items():
if key == "priority_tags":
continue
@@ -608,6 +620,7 @@ class SettingsManager:
'optimizeExampleImages': 'optimize_example_images',
'autoDownloadExampleImages': 'auto_download_example_images',
'blurMatureContent': 'blur_mature_content',
'matureBlurLevel': 'mature_blur_level',
'autoplayOnHover': 'autoplay_on_hover',
'displayDensity': 'display_density',
'cardInfoDisplay': 'card_info_display',
@@ -860,6 +873,13 @@ class SettingsManager:
return normalized
def normalize_mature_blur_level(self, value: Any) -> str:
if isinstance(value, str):
normalized = value.strip().upper()
if normalized in VALID_MATURE_BLUR_LEVELS:
return normalized
return "R"
def normalize_auto_organize_exclusions(self, value: Any) -> List[str]:
if value is None:
return []
@@ -1012,6 +1032,8 @@ class SettingsManager:
value = self.normalize_auto_organize_exclusions(value)
elif key == "metadata_refresh_skip_paths":
value = self.normalize_metadata_refresh_skip_paths(value)
elif key == "mature_blur_level":
value = self.normalize_mature_blur_level(value)
self.settings[key] = value
portable_switch_pending = False
if key == "use_portable_settings" and isinstance(value, bool):

View File

@@ -2,11 +2,12 @@
from __future__ import annotations
from typing import Mapping, Optional, Sequence, Tuple
from typing import Any, Mapping, Optional, Sequence, Tuple
from .constants import NSFW_LEVELS
PreviewMedia = Mapping[str, object]
VALID_MATURE_BLUR_LEVELS = ("PG13", "R", "X", "XXX")
def _extract_nsfw_level(entry: Mapping[str, object]) -> int:
@@ -19,17 +20,36 @@ def _extract_nsfw_level(entry: Mapping[str, object]) -> int:
return 0
def resolve_mature_threshold(settings: Mapping[str, Any] | None) -> int:
"""Resolve the configured mature blur threshold from settings.
Allowed values are ``PG13``, ``R``, ``X``, and ``XXX``. Any invalid or
missing value falls back to ``R``.
"""
if not isinstance(settings, Mapping):
return NSFW_LEVELS.get("R", 4)
raw_level = settings.get("mature_blur_level", "R")
normalized = str(raw_level).strip().upper()
if normalized not in VALID_MATURE_BLUR_LEVELS:
normalized = "R"
return NSFW_LEVELS.get(normalized, NSFW_LEVELS.get("R", 4))
def select_preview_media(
images: Sequence[Mapping[str, object]] | None,
*,
blur_mature_content: bool,
mature_threshold: int | None = None,
) -> Tuple[Optional[PreviewMedia], int]:
"""Select the most appropriate preview media entry.
When ``blur_mature_content`` is enabled we first try to return the first media
item with an ``nsfwLevel`` lower than :pydata:`NSFW_LEVELS["R"]`. If none are
available we return the media entry with the lowest NSFW level. When the
setting is disabled we simply return the first entry.
item with an ``nsfwLevel`` lower than the configured mature threshold
(defaults to :pydata:`NSFW_LEVELS["R"]`). If none are available we return
the media entry with the lowest NSFW level. When the setting is disabled we
simply return the first entry.
"""
if not images:
@@ -45,7 +65,9 @@ def select_preview_media(
if not blur_mature_content:
return selected, selected_level
safe_threshold = NSFW_LEVELS.get("R", 4)
safe_threshold = (
mature_threshold if isinstance(mature_threshold, int) else NSFW_LEVELS.get("R", 4)
)
for candidate in candidates:
level = _extract_nsfw_level(candidate)
if level < safe_threshold:
@@ -60,4 +82,4 @@ def select_preview_media(
return selected, selected_level
__all__ = ["select_preview_media"]
__all__ = ["resolve_mature_threshold", "select_preview_media", "VALID_MATURE_BLUR_LEVELS"]