From ceeab0c998e6636d58c3dc1a8984f5adcf4a43aa Mon Sep 17 00:00:00 2001 From: Will Miao Date: Thu, 26 Mar 2026 18:24:47 +0800 Subject: [PATCH] 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 --- README.md | 2 + locales/de.json | 10 ++- locales/en.json | 12 +++- locales/es.json | 10 ++- locales/fr.json | 10 ++- locales/he.json | 10 ++- locales/ja.json | 10 ++- locales/ko.json | 10 ++- locales/ru.json | 10 ++- locales/zh-CN.json | 10 ++- locales/zh-TW.json | 10 ++- py/services/download_manager.py | 6 +- py/services/model_update_service.py | 13 +++- py/services/preview_asset_service.py | 7 ++- py/services/settings_manager.py | 22 +++++++ py/utils/preview_selection.py | 34 ++++++++-- static/js/components/RecipeCard.js | 5 +- static/js/components/shared/ModelCard.js | 5 +- .../components/shared/showcase/MediaUtils.js | 5 +- .../shared/showcase/ShowcaseView.js | 5 +- static/js/managers/SettingsManager.js | 34 +++++++++- static/js/state/index.js | 1 + static/js/utils/constants.js | 9 +++ .../components/modals/settings_modal.html | 20 ++++++ tests/frontend/state/index.test.js | 3 +- .../constants.matureBlurThreshold.test.js | 18 ++++++ tests/services/test_settings_manager.py | 26 ++++++++ tests/utils/test_preview_selection.py | 62 +++++++++++-------- 28 files changed, 320 insertions(+), 59 deletions(-) create mode 100644 tests/frontend/utils/constants.matureBlurThreshold.test.js diff --git a/README.md b/README.md index 58c5b478..d35cd93a 100644 --- a/README.md +++ b/README.md @@ -179,6 +179,8 @@ Insomnia Art Designs, megakirbs, Brennok, wackop, 2018cfh, Takkan, stone9k, $Met - Context menu for quick actions - Custom notes and usage tips - 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 --- diff --git a/locales/de.json b/locales/de.json index 8729ccf1..c32a0835 100644 --- a/locales/de.json +++ b/locales/de.json @@ -291,7 +291,15 @@ "blurNsfwContent": "NSFW-Inhalte unscharf stellen", "blurNsfwContentHelp": "Nicht jugendfreie (NSFW) Vorschaubilder unscharf stellen", "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": { "autoplayOnHover": "Videos bei Hover automatisch abspielen", diff --git a/locales/en.json b/locales/en.json index 8f3dbcf8..e20e814b 100644 --- a/locales/en.json +++ b/locales/en.json @@ -291,7 +291,15 @@ "blurNsfwContent": "Blur NSFW Content", "blurNsfwContentHelp": "Blur mature (NSFW) content preview images", "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": { "autoplayOnHover": "Autoplay Videos on Hover", @@ -1758,4 +1766,4 @@ "retry": "Retry" } } -} \ No newline at end of file +} diff --git a/locales/es.json b/locales/es.json index 2ac8dd11..f51bd429 100644 --- a/locales/es.json +++ b/locales/es.json @@ -291,7 +291,15 @@ "blurNsfwContent": "Difuminar contenido NSFW", "blurNsfwContentHelp": "Difuminar imágenes de vista previa de contenido para adultos (NSFW)", "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": { "autoplayOnHover": "Reproducir videos automáticamente al pasar el ratón", diff --git a/locales/fr.json b/locales/fr.json index 4c401755..6fe77849 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -291,7 +291,15 @@ "blurNsfwContent": "Flouter le contenu NSFW", "blurNsfwContentHelp": "Flouter les images d'aperçu de contenu pour adultes (NSFW)", "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": { "autoplayOnHover": "Lecture automatique vidéo au survol", diff --git a/locales/he.json b/locales/he.json index b0d3e03c..c4511885 100644 --- a/locales/he.json +++ b/locales/he.json @@ -291,7 +291,15 @@ "blurNsfwContent": "טשטש תוכן NSFW", "blurNsfwContentHelp": "טשטש תמונות תצוגה מקדימה של תוכן למבוגרים (NSFW)", "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": { "autoplayOnHover": "נגן וידאו אוטומטית בריחוף", diff --git a/locales/ja.json b/locales/ja.json index d8fdbb40..3f08ba5e 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -291,7 +291,15 @@ "blurNsfwContent": "NSFWコンテンツをぼかす", "blurNsfwContentHelp": "成人向け(NSFW)コンテンツのプレビュー画像をぼかします", "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": { "autoplayOnHover": "ホバー時に動画を自動再生", diff --git a/locales/ko.json b/locales/ko.json index 2f28182e..dad9454f 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -291,7 +291,15 @@ "blurNsfwContent": "NSFW 콘텐츠 블러 처리", "blurNsfwContentHelp": "성인(NSFW) 콘텐츠 미리보기 이미지를 블러 처리합니다", "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": { "autoplayOnHover": "호버 시 비디오 자동 재생", diff --git a/locales/ru.json b/locales/ru.json index eeb2ceb3..f2ec83d2 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -291,7 +291,15 @@ "blurNsfwContent": "Размывать NSFW контент", "blurNsfwContentHelp": "Размывать превью изображений контента для взрослых (NSFW)", "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": { "autoplayOnHover": "Автовоспроизведение видео при наведении", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index eab2fea4..05cf3724 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -291,7 +291,15 @@ "blurNsfwContent": "模糊 NSFW 内容", "blurNsfwContentHelp": "模糊成熟(NSFW)内容预览图片", "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": { "autoplayOnHover": "悬停时自动播放视频", diff --git a/locales/zh-TW.json b/locales/zh-TW.json index de9de6fc..7968e098 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -291,7 +291,15 @@ "blurNsfwContent": "模糊 NSFW 內容", "blurNsfwContentHelp": "模糊成熟(NSFW)內容預覽圖片", "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": { "autoplayOnHover": "滑鼠懸停自動播放影片", diff --git a/py/services/download_manager.py b/py/services/download_manager.py index 07f60fe8..13a1dbcd 100644 --- a/py/services/download_manager.py +++ b/py/services/download_manager.py @@ -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 diff --git a/py/services/model_update_service.py b/py/services/model_update_service.py index c5e1eebf..d550537b 100644 --- a/py/services/model_update_service.py +++ b/py/services/model_update_service.py @@ -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 diff --git a/py/services/preview_asset_service.py b/py/services/preview_asset_service.py index 9d339a3e..8268553b 100644 --- a/py/services/preview_asset_service.py +++ b/py/services/preview_asset_service.py @@ -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" - diff --git a/py/services/settings_manager.py b/py/services/settings_manager.py index bacd0d53..fe6f2377 100644 --- a/py/services/settings_manager.py +++ b/py/services/settings_manager.py @@ -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): diff --git a/py/utils/preview_selection.py b/py/utils/preview_selection.py index a815a81b..91cfbd30 100644 --- a/py/utils/preview_selection.py +++ b/py/utils/preview_selection.py @@ -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"] diff --git a/static/js/components/RecipeCard.js b/static/js/components/RecipeCard.js index 41b1e3d9..427ac3e9 100644 --- a/static/js/components/RecipeCard.js +++ b/static/js/components/RecipeCard.js @@ -6,7 +6,7 @@ import { modalManager } from '../managers/ModalManager.js'; import { getCurrentPageState } from '../state/index.js'; import { state } from '../state/index.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 { constructor(recipe, clickHandler) { @@ -74,7 +74,8 @@ class RecipeCard { // NSFW blur logic - similar to LoraCard 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) { card.classList.add('nsfw-content'); diff --git a/static/js/components/shared/ModelCard.js b/static/js/components/shared/ModelCard.js index 2dfa8f3e..89689907 100644 --- a/static/js/components/shared/ModelCard.js +++ b/static/js/components/shared/ModelCard.js @@ -4,7 +4,7 @@ import { showModelModal } from './ModelModal.js'; import { toggleShowcase } from './showcase/ShowcaseView.js'; import { bulkManager } from '../../managers/BulkManager.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 { getModelApiClient } from '../../api/modelApiFactory.js'; import { showDeleteModal } from '../../utils/modalUtils.js'; @@ -478,7 +478,8 @@ export function createModelCard(model, modelType) { card.dataset.nsfwLevel = nsfwLevel; // 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) { card.classList.add('nsfw-content'); } diff --git a/static/js/components/shared/showcase/MediaUtils.js b/static/js/components/shared/showcase/MediaUtils.js index b1e36ffd..5e82fda9 100644 --- a/static/js/components/shared/showcase/MediaUtils.js +++ b/static/js/components/shared/showcase/MediaUtils.js @@ -6,7 +6,7 @@ import { showToast, copyToClipboard, getNSFWLevelName } from '../../../utils/uiHelpers.js'; import { state } from '../../../state/index.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'; /** @@ -607,7 +607,8 @@ function applyNsfwLevelChange(mediaWrapper, 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 toggleBtn = mediaWrapper.querySelector('.toggle-blur-btn'); diff --git a/static/js/components/shared/showcase/ShowcaseView.js b/static/js/components/shared/showcase/ShowcaseView.js index 709de8d0..609673ef 100644 --- a/static/js/components/shared/showcase/ShowcaseView.js +++ b/static/js/components/shared/showcase/ShowcaseView.js @@ -6,7 +6,7 @@ import { showToast } from '../../../utils/uiHelpers.js'; import { state } from '../../../state/index.js'; import { modalManager } from '../../../managers/ModalManager.js'; import { translate } from '../../../utils/i18nHelpers.js'; -import { NSFW_LEVELS } from '../../../utils/constants.js'; +import { NSFW_LEVELS, getMatureBlurThreshold } from '../../../utils/constants.js'; import { initLazyLoading, initNsfwBlurHandlers, @@ -184,7 +184,8 @@ function renderMediaItem(img, index, exampleFiles) { // Check if media should be blurred 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 let nsfwText = "Mature Content"; diff --git a/static/js/managers/SettingsManager.js b/static/js/managers/SettingsManager.js index 6a72c67c..b662945b 100644 --- a/static/js/managers/SettingsManager.js +++ b/static/js/managers/SettingsManager.js @@ -10,6 +10,8 @@ import { validatePriorityTagString, getPriorityTagSuggestionsMap, invalidatePrio import { bannerService } from './BannerService.js'; import { sidebarManager } from '../components/SidebarManager.js'; +const VALID_MATURE_BLUR_LEVELS = new Set(['PG13', 'R', 'X', 'XXX']); + export class SettingsManager { constructor() { this.initialized = false; @@ -137,11 +139,25 @@ export class SettingsManager { 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)); 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) { if (Array.isArray(value)) { const sanitized = value @@ -682,6 +698,13 @@ export class SettingsManager { 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'); if (usePortableCheckbox) { usePortableCheckbox.checked = !!state.global.settings.use_portable_settings; @@ -1811,7 +1834,9 @@ export class SettingsManager { const element = document.getElementById(elementId); if (!element) return; - const value = element.value; + const value = settingKey === 'mature_blur_level' + ? this.normalizeMatureBlurLevel(element.value) + : element.value; try { // Update frontend state with mapped keys @@ -1834,7 +1859,12 @@ export class SettingsManager { 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(); } } catch (error) { diff --git a/static/js/state/index.js b/static/js/state/index.js index e28f3153..c3935ba5 100644 --- a/static/js/state/index.js +++ b/static/js/state/index.js @@ -24,6 +24,7 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({ 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', diff --git a/static/js/utils/constants.js b/static/js/utils/constants.js index 328ceb9b..2c50deda 100644 --- a/static/js/utils/constants.js +++ b/static/js/utils/constants.js @@ -309,6 +309,15 @@ export const NSFW_LEVELS = { 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 export const NODE_TYPES = { LORA_LOADER: 1, diff --git a/templates/components/modals/settings_modal.html b/templates/components/modals/settings_modal.html index 5305a619..dd35e081 100644 --- a/templates/components/modals/settings_modal.html +++ b/templates/components/modals/settings_modal.html @@ -281,6 +281,26 @@ + +
+
+
+ +
+
+ +
+
+
diff --git a/tests/frontend/state/index.test.js b/tests/frontend/state/index.test.js index 50e5e6e8..c21268f8 100644 --- a/tests/frontend/state/index.test.js +++ b/tests/frontend/state/index.test.js @@ -15,7 +15,8 @@ describe('state module', () => { expect(defaultSettings).toMatchObject({ civitai_api_key: '', language: 'en', - blur_mature_content: true + blur_mature_content: true, + mature_blur_level: 'R' }); expect(defaultSettings.download_path_templates).toEqual(DEFAULT_PATH_TEMPLATES); diff --git a/tests/frontend/utils/constants.matureBlurThreshold.test.js b/tests/frontend/utils/constants.matureBlurThreshold.test.js new file mode 100644 index 00000000..06382a90 --- /dev/null +++ b/tests/frontend/utils/constants.matureBlurThreshold.test.js @@ -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); + }); +}); diff --git a/tests/services/test_settings_manager.py b/tests/services/test_settings_manager.py index fb4a5240..c33a1aca 100644 --- a/tests/services/test_settings_manager.py +++ b/tests/services/test_settings_manager.py @@ -265,6 +265,32 @@ def test_delete_setting(manager): 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): initial = { "libraries": {"default": {"folder_paths": {}, "default_lora_root": "", "default_checkpoint_root": "", "default_embedding_root": ""}}, diff --git a/tests/utils/test_preview_selection.py b/tests/utils/test_preview_selection.py index 5109c9e8..922f2670 100644 --- a/tests/utils/test_preview_selection.py +++ b/tests/utils/test_preview_selection.py @@ -1,30 +1,7 @@ -from py.utils.preview_selection import select_preview_media +import pytest - -def test_select_preview_prefers_safe_media_when_blurred(): - 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 +from py.utils.constants import NSFW_LEVELS +from py.utils.preview_selection import resolve_mature_threshold, select_preview_media 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 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"]