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

@@ -179,6 +179,8 @@ Insomnia Art Designs, megakirbs, Brennok, wackop, 2018cfh, Takkan, stone9k, $Met
- Context menu for quick actions - Context menu for quick actions
- Custom notes and usage tips - Custom notes and usage tips
- Multi-folder support - Multi-folder support
- Configurable mature blur threshold (`PG13` / `R` / `X` / `XXX`, default `R+`)
- Example: setting threshold to `PG13` blurs `PG13`, `R`, `X`, and `XXX` previews when blur is enabled
- Visual progress indicators during initialization - Visual progress indicators during initialization
--- ---

View File

@@ -291,7 +291,15 @@
"blurNsfwContent": "NSFW-Inhalte unscharf stellen", "blurNsfwContent": "NSFW-Inhalte unscharf stellen",
"blurNsfwContentHelp": "Nicht jugendfreie (NSFW) Vorschaubilder unscharf stellen", "blurNsfwContentHelp": "Nicht jugendfreie (NSFW) Vorschaubilder unscharf stellen",
"showOnlySfw": "Nur SFW-Ergebnisse anzeigen", "showOnlySfw": "Nur SFW-Ergebnisse anzeigen",
"showOnlySfwHelp": "Alle NSFW-Inhalte beim Durchsuchen und Suchen herausfiltern" "showOnlySfwHelp": "Alle NSFW-Inhalte beim Durchsuchen und Suchen herausfiltern",
"matureBlurThreshold": "[TODO: Translate] Mature Blur Threshold",
"matureBlurThresholdHelp": "[TODO: Translate] Set which rating level starts blur filtering when NSFW blur is enabled.",
"matureBlurThresholdOptions": {
"pg13": "[TODO: Translate] PG13 and above",
"r": "[TODO: Translate] R and above (default)",
"x": "[TODO: Translate] X and above",
"xxx": "[TODO: Translate] XXX only"
}
}, },
"videoSettings": { "videoSettings": {
"autoplayOnHover": "Videos bei Hover automatisch abspielen", "autoplayOnHover": "Videos bei Hover automatisch abspielen",

View File

@@ -291,7 +291,15 @@
"blurNsfwContent": "Blur NSFW Content", "blurNsfwContent": "Blur NSFW Content",
"blurNsfwContentHelp": "Blur mature (NSFW) content preview images", "blurNsfwContentHelp": "Blur mature (NSFW) content preview images",
"showOnlySfw": "Show Only SFW Results", "showOnlySfw": "Show Only SFW Results",
"showOnlySfwHelp": "Filter out all NSFW content when browsing and searching" "showOnlySfwHelp": "Filter out all NSFW content when browsing and searching",
"matureBlurThreshold": "Mature Blur Threshold",
"matureBlurThresholdHelp": "Set which rating level starts blur filtering when NSFW blur is enabled.",
"matureBlurThresholdOptions": {
"pg13": "PG13 and above",
"r": "R and above (default)",
"x": "X and above",
"xxx": "XXX only"
}
}, },
"videoSettings": { "videoSettings": {
"autoplayOnHover": "Autoplay Videos on Hover", "autoplayOnHover": "Autoplay Videos on Hover",
@@ -1758,4 +1766,4 @@
"retry": "Retry" "retry": "Retry"
} }
} }
} }

View File

@@ -291,7 +291,15 @@
"blurNsfwContent": "Difuminar contenido NSFW", "blurNsfwContent": "Difuminar contenido NSFW",
"blurNsfwContentHelp": "Difuminar imágenes de vista previa de contenido para adultos (NSFW)", "blurNsfwContentHelp": "Difuminar imágenes de vista previa de contenido para adultos (NSFW)",
"showOnlySfw": "Mostrar solo resultados SFW", "showOnlySfw": "Mostrar solo resultados SFW",
"showOnlySfwHelp": "Filtrar todo el contenido NSFW al navegar y buscar" "showOnlySfwHelp": "Filtrar todo el contenido NSFW al navegar y buscar",
"matureBlurThreshold": "[TODO: Translate] Mature Blur Threshold",
"matureBlurThresholdHelp": "[TODO: Translate] Set which rating level starts blur filtering when NSFW blur is enabled.",
"matureBlurThresholdOptions": {
"pg13": "[TODO: Translate] PG13 and above",
"r": "[TODO: Translate] R and above (default)",
"x": "[TODO: Translate] X and above",
"xxx": "[TODO: Translate] XXX only"
}
}, },
"videoSettings": { "videoSettings": {
"autoplayOnHover": "Reproducir videos automáticamente al pasar el ratón", "autoplayOnHover": "Reproducir videos automáticamente al pasar el ratón",

View File

@@ -291,7 +291,15 @@
"blurNsfwContent": "Flouter le contenu NSFW", "blurNsfwContent": "Flouter le contenu NSFW",
"blurNsfwContentHelp": "Flouter les images d'aperçu de contenu pour adultes (NSFW)", "blurNsfwContentHelp": "Flouter les images d'aperçu de contenu pour adultes (NSFW)",
"showOnlySfw": "Afficher uniquement les résultats SFW", "showOnlySfw": "Afficher uniquement les résultats SFW",
"showOnlySfwHelp": "Filtrer tout le contenu NSFW lors de la navigation et de la recherche" "showOnlySfwHelp": "Filtrer tout le contenu NSFW lors de la navigation et de la recherche",
"matureBlurThreshold": "[TODO: Translate] Mature Blur Threshold",
"matureBlurThresholdHelp": "[TODO: Translate] Set which rating level starts blur filtering when NSFW blur is enabled.",
"matureBlurThresholdOptions": {
"pg13": "[TODO: Translate] PG13 and above",
"r": "[TODO: Translate] R and above (default)",
"x": "[TODO: Translate] X and above",
"xxx": "[TODO: Translate] XXX only"
}
}, },
"videoSettings": { "videoSettings": {
"autoplayOnHover": "Lecture automatique vidéo au survol", "autoplayOnHover": "Lecture automatique vidéo au survol",

View File

@@ -291,7 +291,15 @@
"blurNsfwContent": "טשטש תוכן NSFW", "blurNsfwContent": "טשטש תוכן NSFW",
"blurNsfwContentHelp": "טשטש תמונות תצוגה מקדימה של תוכן למבוגרים (NSFW)", "blurNsfwContentHelp": "טשטש תמונות תצוגה מקדימה של תוכן למבוגרים (NSFW)",
"showOnlySfw": "הצג רק תוצאות SFW", "showOnlySfw": "הצג רק תוצאות SFW",
"showOnlySfwHelp": "סנן את כל התוכן ה-NSFW בעת גלישה וחיפוש" "showOnlySfwHelp": "סנן את כל התוכן ה-NSFW בעת גלישה וחיפוש",
"matureBlurThreshold": "[TODO: Translate] Mature Blur Threshold",
"matureBlurThresholdHelp": "[TODO: Translate] Set which rating level starts blur filtering when NSFW blur is enabled.",
"matureBlurThresholdOptions": {
"pg13": "[TODO: Translate] PG13 and above",
"r": "[TODO: Translate] R and above (default)",
"x": "[TODO: Translate] X and above",
"xxx": "[TODO: Translate] XXX only"
}
}, },
"videoSettings": { "videoSettings": {
"autoplayOnHover": "נגן וידאו אוטומטית בריחוף", "autoplayOnHover": "נגן וידאו אוטומטית בריחוף",

View File

@@ -291,7 +291,15 @@
"blurNsfwContent": "NSFWコンテンツをぼかす", "blurNsfwContent": "NSFWコンテンツをぼかす",
"blurNsfwContentHelp": "成人向けNSFWコンテンツのプレビュー画像をぼかします", "blurNsfwContentHelp": "成人向けNSFWコンテンツのプレビュー画像をぼかします",
"showOnlySfw": "SFWコンテンツのみ表示", "showOnlySfw": "SFWコンテンツのみ表示",
"showOnlySfwHelp": "閲覧と検索時にすべてのNSFWコンテンツを除外します" "showOnlySfwHelp": "閲覧と検索時にすべてのNSFWコンテンツを除外します",
"matureBlurThreshold": "[TODO: Translate] Mature Blur Threshold",
"matureBlurThresholdHelp": "[TODO: Translate] Set which rating level starts blur filtering when NSFW blur is enabled.",
"matureBlurThresholdOptions": {
"pg13": "[TODO: Translate] PG13 and above",
"r": "[TODO: Translate] R and above (default)",
"x": "[TODO: Translate] X and above",
"xxx": "[TODO: Translate] XXX only"
}
}, },
"videoSettings": { "videoSettings": {
"autoplayOnHover": "ホバー時に動画を自動再生", "autoplayOnHover": "ホバー時に動画を自動再生",

View File

@@ -291,7 +291,15 @@
"blurNsfwContent": "NSFW 콘텐츠 블러 처리", "blurNsfwContent": "NSFW 콘텐츠 블러 처리",
"blurNsfwContentHelp": "성인(NSFW) 콘텐츠 미리보기 이미지를 블러 처리합니다", "blurNsfwContentHelp": "성인(NSFW) 콘텐츠 미리보기 이미지를 블러 처리합니다",
"showOnlySfw": "SFW 결과만 표시", "showOnlySfw": "SFW 결과만 표시",
"showOnlySfwHelp": "탐색 및 검색 시 모든 NSFW 콘텐츠를 필터링합니다" "showOnlySfwHelp": "탐색 및 검색 시 모든 NSFW 콘텐츠를 필터링합니다",
"matureBlurThreshold": "[TODO: Translate] Mature Blur Threshold",
"matureBlurThresholdHelp": "[TODO: Translate] Set which rating level starts blur filtering when NSFW blur is enabled.",
"matureBlurThresholdOptions": {
"pg13": "[TODO: Translate] PG13 and above",
"r": "[TODO: Translate] R and above (default)",
"x": "[TODO: Translate] X and above",
"xxx": "[TODO: Translate] XXX only"
}
}, },
"videoSettings": { "videoSettings": {
"autoplayOnHover": "호버 시 비디오 자동 재생", "autoplayOnHover": "호버 시 비디오 자동 재생",

View File

@@ -291,7 +291,15 @@
"blurNsfwContent": "Размывать NSFW контент", "blurNsfwContent": "Размывать NSFW контент",
"blurNsfwContentHelp": "Размывать превью изображений контента для взрослых (NSFW)", "blurNsfwContentHelp": "Размывать превью изображений контента для взрослых (NSFW)",
"showOnlySfw": "Показывать только SFW результаты", "showOnlySfw": "Показывать только SFW результаты",
"showOnlySfwHelp": "Фильтровать весь NSFW контент при просмотре и поиске" "showOnlySfwHelp": "Фильтровать весь NSFW контент при просмотре и поиске",
"matureBlurThreshold": "[TODO: Translate] Mature Blur Threshold",
"matureBlurThresholdHelp": "[TODO: Translate] Set which rating level starts blur filtering when NSFW blur is enabled.",
"matureBlurThresholdOptions": {
"pg13": "[TODO: Translate] PG13 and above",
"r": "[TODO: Translate] R and above (default)",
"x": "[TODO: Translate] X and above",
"xxx": "[TODO: Translate] XXX only"
}
}, },
"videoSettings": { "videoSettings": {
"autoplayOnHover": "Автовоспроизведение видео при наведении", "autoplayOnHover": "Автовоспроизведение видео при наведении",

View File

@@ -291,7 +291,15 @@
"blurNsfwContent": "模糊 NSFW 内容", "blurNsfwContent": "模糊 NSFW 内容",
"blurNsfwContentHelp": "模糊成熟NSFW内容预览图片", "blurNsfwContentHelp": "模糊成熟NSFW内容预览图片",
"showOnlySfw": "仅显示 SFW 结果", "showOnlySfw": "仅显示 SFW 结果",
"showOnlySfwHelp": "浏览和搜索时过滤所有 NSFW 内容" "showOnlySfwHelp": "浏览和搜索时过滤所有 NSFW 内容",
"matureBlurThreshold": "[TODO: Translate] Mature Blur Threshold",
"matureBlurThresholdHelp": "[TODO: Translate] Set which rating level starts blur filtering when NSFW blur is enabled.",
"matureBlurThresholdOptions": {
"pg13": "[TODO: Translate] PG13 and above",
"r": "[TODO: Translate] R and above (default)",
"x": "[TODO: Translate] X and above",
"xxx": "[TODO: Translate] XXX only"
}
}, },
"videoSettings": { "videoSettings": {
"autoplayOnHover": "悬停时自动播放视频", "autoplayOnHover": "悬停时自动播放视频",

View File

@@ -291,7 +291,15 @@
"blurNsfwContent": "模糊 NSFW 內容", "blurNsfwContent": "模糊 NSFW 內容",
"blurNsfwContentHelp": "模糊成熟NSFW內容預覽圖片", "blurNsfwContentHelp": "模糊成熟NSFW內容預覽圖片",
"showOnlySfw": "僅顯示 SFW 結果", "showOnlySfw": "僅顯示 SFW 結果",
"showOnlySfwHelp": "瀏覽和搜尋時過濾所有 NSFW 內容" "showOnlySfwHelp": "瀏覽和搜尋時過濾所有 NSFW 內容",
"matureBlurThreshold": "[TODO: Translate] Mature Blur Threshold",
"matureBlurThresholdHelp": "[TODO: Translate] Set which rating level starts blur filtering when NSFW blur is enabled.",
"matureBlurThresholdOptions": {
"pg13": "[TODO: Translate] PG13 and above",
"r": "[TODO: Translate] R and above (default)",
"x": "[TODO: Translate] X and above",
"xxx": "[TODO: Translate] XXX only"
}
}, },
"videoSettings": { "videoSettings": {
"autoplayOnHover": "滑鼠懸停自動播放影片", "autoplayOnHover": "滑鼠懸停自動播放影片",

View File

@@ -16,7 +16,7 @@ from ..utils.constants import (
VALID_LORA_TYPES, VALID_LORA_TYPES,
) )
from ..utils.civitai_utils import rewrite_preview_url 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.utils import sanitize_folder_name
from ..utils.exif_utils import ExifUtils from ..utils.exif_utils import ExifUtils
from ..utils.metadata_manager import MetadataManager from ..utils.metadata_manager import MetadataManager
@@ -846,9 +846,13 @@ class DownloadManager:
blur_mature_content = bool( blur_mature_content = bool(
settings_manager.get("blur_mature_content", True) 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( selected_image, nsfw_level = select_preview_media(
images, images,
blur_mature_content=blur_mature_content, blur_mature_content=blur_mature_content,
mature_threshold=mature_threshold,
) )
preview_url = selected_image.get("url") if selected_image else None 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 .errors import RateLimitError, ResourceNotFoundError
from .settings_manager import get_settings_manager from .settings_manager import get_settings_manager
from ..utils.civitai_utils import rewrite_preview_url 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__) logger = logging.getLogger(__name__)
@@ -1252,14 +1252,23 @@ class ModelUpdateService:
return None return None
blur_mature_content = True blur_mature_content = True
mature_threshold = resolve_mature_threshold({"mature_blur_level": "R"})
settings = getattr(self, "_settings", None) settings = getattr(self, "_settings", None)
if settings is not None and hasattr(settings, "get"): if settings is not None and hasattr(settings, "get"):
try: try:
blur_mature_content = bool(settings.get("blur_mature_content", True)) 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 except Exception: # pragma: no cover - defensive guard
blur_mature_content = True 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: if not selected:
return None return None

View File

@@ -9,7 +9,7 @@ from urllib.parse import urlparse
from ..utils.constants import CARD_PREVIEW_WIDTH, PREVIEW_EXTENSIONS from ..utils.constants import CARD_PREVIEW_WIDTH, PREVIEW_EXTENSIONS
from ..utils.civitai_utils import rewrite_preview_url 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 from .settings_manager import get_settings_manager
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -49,9 +49,13 @@ class PreviewAssetService:
blur_mature_content = bool( blur_mature_content = bool(
settings_manager.get("blur_mature_content", True) 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( first_preview, nsfw_level = select_preview_media(
images, images,
blur_mature_content=blur_mature_content, blur_mature_content=blur_mature_content,
mature_threshold=mature_threshold,
) )
if not first_preview: if not first_preview:
@@ -216,4 +220,3 @@ class PreviewAssetService:
if "webm" in content_type: if "webm" in content_type:
return ".webm" return ".webm"
return ".mp4" 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 platformdirs import user_config_dir
from ..utils.constants import DEFAULT_HASH_CHUNK_SIZE_MB, DEFAULT_PRIORITY_TAG_CONFIG 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.settings_paths import APP_NAME, ensure_settings_file, get_legacy_settings_path
from ..utils.tag_priorities import ( from ..utils.tag_priorities import (
PriorityTagEntry, PriorityTagEntry,
@@ -59,6 +60,7 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
"optimize_example_images": True, "optimize_example_images": True,
"auto_download_example_images": False, "auto_download_example_images": False,
"blur_mature_content": True, "blur_mature_content": True,
"mature_blur_level": "R",
"autoplay_on_hover": False, "autoplay_on_hover": False,
"display_density": "default", "display_density": "default",
"card_info_display": "always", "card_info_display": "always",
@@ -274,6 +276,16 @@ class SettingsManager:
self.settings["metadata_refresh_skip_paths"] = [] self.settings["metadata_refresh_skip_paths"] = []
inserted_defaults = True 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(): for key, value in defaults.items():
if key == "priority_tags": if key == "priority_tags":
continue continue
@@ -608,6 +620,7 @@ class SettingsManager:
'optimizeExampleImages': 'optimize_example_images', 'optimizeExampleImages': 'optimize_example_images',
'autoDownloadExampleImages': 'auto_download_example_images', 'autoDownloadExampleImages': 'auto_download_example_images',
'blurMatureContent': 'blur_mature_content', 'blurMatureContent': 'blur_mature_content',
'matureBlurLevel': 'mature_blur_level',
'autoplayOnHover': 'autoplay_on_hover', 'autoplayOnHover': 'autoplay_on_hover',
'displayDensity': 'display_density', 'displayDensity': 'display_density',
'cardInfoDisplay': 'card_info_display', 'cardInfoDisplay': 'card_info_display',
@@ -860,6 +873,13 @@ class SettingsManager:
return normalized 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]: def normalize_auto_organize_exclusions(self, value: Any) -> List[str]:
if value is None: if value is None:
return [] return []
@@ -1012,6 +1032,8 @@ class SettingsManager:
value = self.normalize_auto_organize_exclusions(value) value = self.normalize_auto_organize_exclusions(value)
elif key == "metadata_refresh_skip_paths": elif key == "metadata_refresh_skip_paths":
value = self.normalize_metadata_refresh_skip_paths(value) value = self.normalize_metadata_refresh_skip_paths(value)
elif key == "mature_blur_level":
value = self.normalize_mature_blur_level(value)
self.settings[key] = value self.settings[key] = value
portable_switch_pending = False portable_switch_pending = False
if key == "use_portable_settings" and isinstance(value, bool): if key == "use_portable_settings" and isinstance(value, bool):

View File

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

View File

@@ -6,7 +6,7 @@ import { modalManager } from '../managers/ModalManager.js';
import { getCurrentPageState } from '../state/index.js'; import { getCurrentPageState } from '../state/index.js';
import { state } from '../state/index.js'; import { state } from '../state/index.js';
import { bulkManager } from '../managers/BulkManager.js'; import { bulkManager } from '../managers/BulkManager.js';
import { NSFW_LEVELS, getBaseModelAbbreviation } from '../utils/constants.js'; import { NSFW_LEVELS, getBaseModelAbbreviation, getMatureBlurThreshold } from '../utils/constants.js';
class RecipeCard { class RecipeCard {
constructor(recipe, clickHandler) { constructor(recipe, clickHandler) {
@@ -74,7 +74,8 @@ class RecipeCard {
// NSFW blur logic - similar to LoraCard // NSFW blur logic - similar to LoraCard
const nsfwLevel = this.recipe.preview_nsfw_level !== undefined ? this.recipe.preview_nsfw_level : 0; const nsfwLevel = this.recipe.preview_nsfw_level !== undefined ? this.recipe.preview_nsfw_level : 0;
const shouldBlur = state.settings.blur_mature_content && nsfwLevel > NSFW_LEVELS.PG13; const matureBlurThreshold = getMatureBlurThreshold(state.settings);
const shouldBlur = state.settings.blur_mature_content && nsfwLevel >= matureBlurThreshold;
if (shouldBlur) { if (shouldBlur) {
card.classList.add('nsfw-content'); card.classList.add('nsfw-content');

View File

@@ -4,7 +4,7 @@ import { showModelModal } from './ModelModal.js';
import { toggleShowcase } from './showcase/ShowcaseView.js'; import { toggleShowcase } from './showcase/ShowcaseView.js';
import { bulkManager } from '../../managers/BulkManager.js'; import { bulkManager } from '../../managers/BulkManager.js';
import { modalManager } from '../../managers/ModalManager.js'; import { modalManager } from '../../managers/ModalManager.js';
import { NSFW_LEVELS, getBaseModelAbbreviation, getSubTypeAbbreviation, MODEL_SUBTYPE_DISPLAY_NAMES } from '../../utils/constants.js'; import { NSFW_LEVELS, getBaseModelAbbreviation, getSubTypeAbbreviation, getMatureBlurThreshold, MODEL_SUBTYPE_DISPLAY_NAMES } from '../../utils/constants.js';
import { MODEL_TYPES } from '../../api/apiConfig.js'; import { MODEL_TYPES } from '../../api/apiConfig.js';
import { getModelApiClient } from '../../api/modelApiFactory.js'; import { getModelApiClient } from '../../api/modelApiFactory.js';
import { showDeleteModal } from '../../utils/modalUtils.js'; import { showDeleteModal } from '../../utils/modalUtils.js';
@@ -478,7 +478,8 @@ export function createModelCard(model, modelType) {
card.dataset.nsfwLevel = nsfwLevel; card.dataset.nsfwLevel = nsfwLevel;
// Determine if the preview should be blurred based on NSFW level and user settings // Determine if the preview should be blurred based on NSFW level and user settings
const shouldBlur = state.settings.blur_mature_content && nsfwLevel > NSFW_LEVELS.PG13; const matureBlurThreshold = getMatureBlurThreshold(state.settings);
const shouldBlur = state.settings.blur_mature_content && nsfwLevel >= matureBlurThreshold;
if (shouldBlur) { if (shouldBlur) {
card.classList.add('nsfw-content'); card.classList.add('nsfw-content');
} }

View File

@@ -6,7 +6,7 @@
import { showToast, copyToClipboard, getNSFWLevelName } from '../../../utils/uiHelpers.js'; import { showToast, copyToClipboard, getNSFWLevelName } from '../../../utils/uiHelpers.js';
import { state } from '../../../state/index.js'; import { state } from '../../../state/index.js';
import { getModelApiClient } from '../../../api/modelApiFactory.js'; import { getModelApiClient } from '../../../api/modelApiFactory.js';
import { NSFW_LEVELS } from '../../../utils/constants.js'; import { NSFW_LEVELS, getMatureBlurThreshold } from '../../../utils/constants.js';
import { getNsfwLevelSelector } from '../NsfwLevelSelector.js'; import { getNsfwLevelSelector } from '../NsfwLevelSelector.js';
/** /**
@@ -607,7 +607,8 @@ function applyNsfwLevelChange(mediaWrapper, nsfwLevel) {
} }
mediaWrapper.dataset.nsfwLevel = String(nsfwLevel); mediaWrapper.dataset.nsfwLevel = String(nsfwLevel);
const shouldBlur = state.settings.blur_mature_content && nsfwLevel > NSFW_LEVELS.PG13; const matureBlurThreshold = getMatureBlurThreshold(state.settings);
const shouldBlur = state.settings.blur_mature_content && nsfwLevel >= matureBlurThreshold;
let overlay = mediaWrapper.querySelector('.nsfw-overlay'); let overlay = mediaWrapper.querySelector('.nsfw-overlay');
let toggleBtn = mediaWrapper.querySelector('.toggle-blur-btn'); let toggleBtn = mediaWrapper.querySelector('.toggle-blur-btn');

View File

@@ -6,7 +6,7 @@ import { showToast } from '../../../utils/uiHelpers.js';
import { state } from '../../../state/index.js'; import { state } from '../../../state/index.js';
import { modalManager } from '../../../managers/ModalManager.js'; import { modalManager } from '../../../managers/ModalManager.js';
import { translate } from '../../../utils/i18nHelpers.js'; import { translate } from '../../../utils/i18nHelpers.js';
import { NSFW_LEVELS } from '../../../utils/constants.js'; import { NSFW_LEVELS, getMatureBlurThreshold } from '../../../utils/constants.js';
import { import {
initLazyLoading, initLazyLoading,
initNsfwBlurHandlers, initNsfwBlurHandlers,
@@ -184,7 +184,8 @@ function renderMediaItem(img, index, exampleFiles) {
// Check if media should be blurred // Check if media should be blurred
const nsfwLevel = img.nsfwLevel !== undefined ? img.nsfwLevel : 0; const nsfwLevel = img.nsfwLevel !== undefined ? img.nsfwLevel : 0;
const shouldBlur = state.settings.blur_mature_content && nsfwLevel > NSFW_LEVELS.PG13; const matureBlurThreshold = getMatureBlurThreshold(state.settings);
const shouldBlur = state.settings.blur_mature_content && nsfwLevel >= matureBlurThreshold;
// Determine NSFW warning text based on level // Determine NSFW warning text based on level
let nsfwText = "Mature Content"; let nsfwText = "Mature Content";

View File

@@ -10,6 +10,8 @@ import { validatePriorityTagString, getPriorityTagSuggestionsMap, invalidatePrio
import { bannerService } from './BannerService.js'; import { bannerService } from './BannerService.js';
import { sidebarManager } from '../components/SidebarManager.js'; import { sidebarManager } from '../components/SidebarManager.js';
const VALID_MATURE_BLUR_LEVELS = new Set(['PG13', 'R', 'X', 'XXX']);
export class SettingsManager { export class SettingsManager {
constructor() { constructor() {
this.initialized = false; this.initialized = false;
@@ -137,11 +139,25 @@ export class SettingsManager {
backendSettings?.metadata_refresh_skip_paths ?? defaults.metadata_refresh_skip_paths backendSettings?.metadata_refresh_skip_paths ?? defaults.metadata_refresh_skip_paths
); );
merged.mature_blur_level = this.normalizeMatureBlurLevel(
backendSettings?.mature_blur_level ?? defaults.mature_blur_level
);
Object.keys(merged).forEach(key => this.backendSettingKeys.add(key)); Object.keys(merged).forEach(key => this.backendSettingKeys.add(key));
return merged; return merged;
} }
normalizeMatureBlurLevel(value) {
if (typeof value === 'string') {
const normalized = value.trim().toUpperCase();
if (VALID_MATURE_BLUR_LEVELS.has(normalized)) {
return normalized;
}
}
return 'R';
}
normalizePatternList(value) { normalizePatternList(value) {
if (Array.isArray(value)) { if (Array.isArray(value)) {
const sanitized = value const sanitized = value
@@ -682,6 +698,13 @@ export class SettingsManager {
showOnlySFWCheckbox.checked = state.global.settings.show_only_sfw ?? false; showOnlySFWCheckbox.checked = state.global.settings.show_only_sfw ?? false;
} }
const matureBlurLevelSelect = document.getElementById('matureBlurLevel');
if (matureBlurLevelSelect) {
matureBlurLevelSelect.value = this.normalizeMatureBlurLevel(
state.global.settings.mature_blur_level
);
}
const usePortableCheckbox = document.getElementById('usePortableSettings'); const usePortableCheckbox = document.getElementById('usePortableSettings');
if (usePortableCheckbox) { if (usePortableCheckbox) {
usePortableCheckbox.checked = !!state.global.settings.use_portable_settings; usePortableCheckbox.checked = !!state.global.settings.use_portable_settings;
@@ -1811,7 +1834,9 @@ export class SettingsManager {
const element = document.getElementById(elementId); const element = document.getElementById(elementId);
if (!element) return; if (!element) return;
const value = element.value; const value = settingKey === 'mature_blur_level'
? this.normalizeMatureBlurLevel(element.value)
: element.value;
try { try {
// Update frontend state with mapped keys // Update frontend state with mapped keys
@@ -1834,7 +1859,12 @@ export class SettingsManager {
showToast('toast.settings.settingsUpdated', { setting: settingKey.replace(/_/g, ' ') }, 'success'); showToast('toast.settings.settingsUpdated', { setting: settingKey.replace(/_/g, ' ') }, 'success');
if (settingKey === 'model_name_display' || settingKey === 'model_card_footer_action' || settingKey === 'update_flag_strategy') { if (
settingKey === 'model_name_display'
|| settingKey === 'model_card_footer_action'
|| settingKey === 'update_flag_strategy'
|| settingKey === 'mature_blur_level'
) {
this.reloadContent(); this.reloadContent();
} }
} catch (error) { } catch (error) {

View File

@@ -24,6 +24,7 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
optimize_example_images: true, optimize_example_images: true,
auto_download_example_images: false, auto_download_example_images: false,
blur_mature_content: true, blur_mature_content: true,
mature_blur_level: 'R',
autoplay_on_hover: false, autoplay_on_hover: false,
display_density: 'default', display_density: 'default',
card_info_display: 'always', card_info_display: 'always',

View File

@@ -309,6 +309,15 @@ export const NSFW_LEVELS = {
BLOCKED: 32 BLOCKED: 32
}; };
export const VALID_MATURE_BLUR_LEVELS = ['PG13', 'R', 'X', 'XXX'];
export function getMatureBlurThreshold(settings = {}) {
const rawValue = settings?.mature_blur_level;
const normalizedValue = typeof rawValue === 'string' ? rawValue.trim().toUpperCase() : '';
const levelName = VALID_MATURE_BLUR_LEVELS.includes(normalizedValue) ? normalizedValue : 'R';
return NSFW_LEVELS[levelName] ?? NSFW_LEVELS.R;
}
// Node type constants // Node type constants
export const NODE_TYPES = { export const NODE_TYPES = {
LORA_LOADER: 1, LORA_LOADER: 1,

View File

@@ -281,6 +281,26 @@
</div> </div>
</div> </div>
</div> </div>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="matureBlurLevel">
{{ t('settings.contentFiltering.matureBlurThreshold') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.contentFiltering.matureBlurThresholdHelp') }}"></i>
</label>
</div>
<div class="setting-control select-control">
<select id="matureBlurLevel"
onchange="settingsManager.saveSelectSetting('matureBlurLevel', 'mature_blur_level')">
<option value="PG13">{{ t('settings.contentFiltering.matureBlurThresholdOptions.pg13') }}</option>
<option value="R">{{ t('settings.contentFiltering.matureBlurThresholdOptions.r') }}</option>
<option value="X">{{ t('settings.contentFiltering.matureBlurThresholdOptions.x') }}</option>
<option value="XXX">{{ t('settings.contentFiltering.matureBlurThresholdOptions.xxx') }}</option>
</select>
</div>
</div>
</div>
</div> </div>
<!-- Video Settings --> <!-- Video Settings -->

View File

@@ -15,7 +15,8 @@ describe('state module', () => {
expect(defaultSettings).toMatchObject({ expect(defaultSettings).toMatchObject({
civitai_api_key: '', civitai_api_key: '',
language: 'en', language: 'en',
blur_mature_content: true blur_mature_content: true,
mature_blur_level: 'R'
}); });
expect(defaultSettings.download_path_templates).toEqual(DEFAULT_PATH_TEMPLATES); expect(defaultSettings.download_path_templates).toEqual(DEFAULT_PATH_TEMPLATES);

View File

@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest';
import { NSFW_LEVELS, getMatureBlurThreshold } from '../../../static/js/utils/constants.js';
describe('getMatureBlurThreshold', () => {
it('returns configured PG13 threshold', () => {
expect(getMatureBlurThreshold({ mature_blur_level: 'PG13' })).toBe(NSFW_LEVELS.PG13);
});
it('normalizes lowercase values', () => {
expect(getMatureBlurThreshold({ mature_blur_level: 'x' })).toBe(NSFW_LEVELS.X);
});
it('falls back to R when value is invalid or missing', () => {
expect(getMatureBlurThreshold({ mature_blur_level: 'invalid' })).toBe(NSFW_LEVELS.R);
expect(getMatureBlurThreshold({})).toBe(NSFW_LEVELS.R);
});
});

View File

@@ -265,6 +265,32 @@ def test_delete_setting(manager):
assert manager.get("example") is None assert manager.get("example") is None
def test_missing_mature_blur_level_defaults_to_r(tmp_path, monkeypatch):
manager = _create_manager_with_settings(
tmp_path,
monkeypatch,
{
"blur_mature_content": True,
"folder_paths": {},
},
)
assert manager.get("mature_blur_level") == "R"
def test_invalid_mature_blur_level_is_normalized_to_r(tmp_path, monkeypatch):
manager = _create_manager_with_settings(
tmp_path,
monkeypatch,
{
"mature_blur_level": "unsafe",
"folder_paths": {},
},
)
assert manager.get("mature_blur_level") == "R"
def test_model_name_display_setting_notifies_scanners(tmp_path, monkeypatch): def test_model_name_display_setting_notifies_scanners(tmp_path, monkeypatch):
initial = { initial = {
"libraries": {"default": {"folder_paths": {}, "default_lora_root": "", "default_checkpoint_root": "", "default_embedding_root": ""}}, "libraries": {"default": {"folder_paths": {}, "default_lora_root": "", "default_checkpoint_root": "", "default_embedding_root": ""}},

View File

@@ -1,30 +1,7 @@
from py.utils.preview_selection import select_preview_media import pytest
from py.utils.constants import NSFW_LEVELS
def test_select_preview_prefers_safe_media_when_blurred(): from py.utils.preview_selection import resolve_mature_threshold, select_preview_media
images = [
{"url": "nsfw", "type": "image", "nsfwLevel": 8},
{"url": "mid", "type": "image", "nsfwLevel": 4},
{"url": "safe", "type": "image", "nsfwLevel": 1},
]
selected, level = select_preview_media(images, blur_mature_content=True)
assert selected["url"] == "safe"
assert level == 1
def test_select_preview_returns_lowest_when_no_safe_media():
images = [
{"url": "x", "type": "image", "nsfwLevel": 16},
{"url": "r", "type": "image", "nsfwLevel": 4},
{"url": "xx", "type": "image", "nsfwLevel": 8},
]
selected, level = select_preview_media(images, blur_mature_content=True)
assert selected["url"] == "r"
assert level == 4
def test_select_preview_returns_first_when_blur_disabled(): def test_select_preview_returns_first_when_blur_disabled():
@@ -37,3 +14,36 @@ def test_select_preview_returns_first_when_blur_disabled():
assert selected["url"] == "nsfw" assert selected["url"] == "nsfw"
assert level == 32 assert level == 32
@pytest.mark.parametrize(
("threshold_name", "expected_url"),
[
("PG13", "pg"),
("R", "pg13"),
("X", "r"),
("XXX", "x"),
],
)
def test_select_preview_respects_configurable_threshold(threshold_name, expected_url):
images = [
{"url": "xxx", "type": "image", "nsfwLevel": NSFW_LEVELS["XXX"]},
{"url": "x", "type": "image", "nsfwLevel": NSFW_LEVELS["X"]},
{"url": "r", "type": "image", "nsfwLevel": NSFW_LEVELS["R"]},
{"url": "pg13", "type": "image", "nsfwLevel": NSFW_LEVELS["PG13"]},
{"url": "pg", "type": "image", "nsfwLevel": NSFW_LEVELS["PG"]},
]
selected, level = select_preview_media(
images,
blur_mature_content=True,
mature_threshold=NSFW_LEVELS[threshold_name],
)
assert selected["url"] == expected_url
assert level == next(item["nsfwLevel"] for item in images if item["url"] == expected_url)
def test_resolve_mature_threshold_falls_back_to_r_for_invalid_value():
assert resolve_mature_threshold({"mature_blur_level": "invalid"}) == NSFW_LEVELS["R"]
assert resolve_mature_threshold({}) == NSFW_LEVELS["R"]