diff --git a/README.md b/README.md index 97988d9..d0b205e 100644 --- a/README.md +++ b/README.md @@ -238,7 +238,7 @@ mkdir -p data cache media downloads ```bash cat > .env <<'EOF' # 固定版本;需要升级时改成新的 MediaStationGo-vX.Y.Z 后执行 docker compose pull && docker compose up -d -MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.26 +MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.27 MEDIASTATION_HTTP_PORT=18080 # 程序数据和缓存建议放在 MediaStationGo 部署目录下,便于备份和迁移。 @@ -307,7 +307,7 @@ vim docker-compose.yml # # 镜像版本: # 默认拉取 latest;如需固定版本,创建 .env 并写入: -# MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.26 +# MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.27 # # 路径映射总览: # /data 程序数据目录。保存 SQLite 数据库、JWT secret、系统配置等,必须持久化。 @@ -516,7 +516,7 @@ docker compose up -d ```bash cat > .env <<'EOF' -MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.26 +MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.27 MEDIASTATION_HTTP_PORT=18080 MEDIASTATION_DATA_DIR=./data MEDIASTATION_CACHE_DIR=./cache @@ -779,26 +779,26 @@ cd MediaStationGo | 平台 | 包名示例 | | --- | --- | -| Linux x86_64 | `MediaStationGo-v0.0.26-linux-amd64.tar.gz` | -| Linux ARM64 | `MediaStationGo-v0.0.26-linux-arm64.tar.gz` | -| Windows x86_64 | `MediaStationGo-v0.0.26-windows-amd64.zip` | -| macOS Intel | `MediaStationGo-v0.0.26-darwin-amd64.tar.gz` | -| macOS Apple Silicon | `MediaStationGo-v0.0.26-darwin-arm64.tar.gz` | +| Linux x86_64 | `MediaStationGo-v0.0.27-linux-amd64.tar.gz` | +| Linux ARM64 | `MediaStationGo-v0.0.27-linux-arm64.tar.gz` | +| Windows x86_64 | `MediaStationGo-v0.0.27-windows-amd64.zip` | +| macOS Intel | `MediaStationGo-v0.0.27-darwin-amd64.tar.gz` | +| macOS Apple Silicon | `MediaStationGo-v0.0.27-darwin-arm64.tar.gz` | 部署步骤: ```bash # Linux 示例 -tar -xzf MediaStationGo-v0.0.26-linux-amd64.tar.gz -cd MediaStationGo-v0.0.26-linux-amd64 +tar -xzf MediaStationGo-v0.0.27-linux-amd64.tar.gz +cd MediaStationGo-v0.0.27-linux-amd64 MEDIASTATION_APP_PORT=18080 ./mediastation-go ``` Windows: ```powershell -Expand-Archive .\MediaStationGo-v0.0.26-windows-amd64.zip -cd .\MediaStationGo-v0.0.26-windows-amd64 +Expand-Archive .\MediaStationGo-v0.0.27-windows-amd64.zip +cd .\MediaStationGo-v0.0.27-windows-amd64 $env:MEDIASTATION_APP_PORT = "18080" .\mediastation-go.exe ``` diff --git a/README_EN.md b/README_EN.md index 4733c98..c8a6ea8 100644 --- a/README_EN.md +++ b/README_EN.md @@ -235,7 +235,7 @@ mkdir -p data cache media downloads ```bash cat > .env <<'EOF' -MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.26 +MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.27 MEDIASTATION_HTTP_PORT=18080 MEDIASTATION_DATA_DIR=./data MEDIASTATION_CACHE_DIR=./cache @@ -344,7 +344,7 @@ For production, pin a specific release tag instead of using `latest`. Recommende ```bash cat > .env <<'EOF' -MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.26 +MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.27 MEDIASTATION_HTTP_PORT=18080 MEDIASTATION_DATA_DIR=./data MEDIASTATION_CACHE_DIR=./cache @@ -593,25 +593,25 @@ Each release provides multi-platform archives: | Platform | Package example | | --- | --- | -| Linux x86_64 | `MediaStationGo-v0.0.26-linux-amd64.tar.gz` | -| Linux ARM64 | `MediaStationGo-v0.0.26-linux-arm64.tar.gz` | -| Windows x86_64 | `MediaStationGo-v0.0.26-windows-amd64.zip` | -| macOS Intel | `MediaStationGo-v0.0.26-darwin-amd64.tar.gz` | -| macOS Apple Silicon | `MediaStationGo-v0.0.26-darwin-arm64.tar.gz` | +| Linux x86_64 | `MediaStationGo-v0.0.27-linux-amd64.tar.gz` | +| Linux ARM64 | `MediaStationGo-v0.0.27-linux-arm64.tar.gz` | +| Windows x86_64 | `MediaStationGo-v0.0.27-windows-amd64.zip` | +| macOS Intel | `MediaStationGo-v0.0.27-darwin-amd64.tar.gz` | +| macOS Apple Silicon | `MediaStationGo-v0.0.27-darwin-arm64.tar.gz` | Linux example: ```bash -tar -xzf MediaStationGo-v0.0.26-linux-amd64.tar.gz -cd MediaStationGo-v0.0.26-linux-amd64 +tar -xzf MediaStationGo-v0.0.27-linux-amd64.tar.gz +cd MediaStationGo-v0.0.27-linux-amd64 MEDIASTATION_APP_PORT=18080 ./mediastation-go ``` Windows example: ```powershell -Expand-Archive .\MediaStationGo-v0.0.26-windows-amd64.zip -cd .\MediaStationGo-v0.0.26-windows-amd64 +Expand-Archive .\MediaStationGo-v0.0.27-windows-amd64.zip +cd .\MediaStationGo-v0.0.27-windows-amd64 $env:MEDIASTATION_APP_PORT = "18080" .\mediastation-go.exe ``` diff --git a/docker-compose.yml b/docker-compose.yml index 90c0cc7..c3056bc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,7 +17,7 @@ # # 镜像版本: # 默认拉取 latest;如需固定版本,创建 .env 并写入: -# MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.26 +# MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.27 # # 路径映射总览: # /data 程序数据目录。保存 SQLite 数据库、JWT secret、系统配置等,必须持久化。 diff --git a/internal/handler/admin.go b/internal/handler/admin.go index 3598b21..99917b5 100644 --- a/internal/handler/admin.go +++ b/internal/handler/admin.go @@ -182,10 +182,19 @@ func updateSettingHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } + oldValue := "" + if req.Key == service.AdultLibraryIDsSettingKey { + oldValue, _ = svc.Repo.Setting.Get(c.Request.Context(), req.Key) + } if err := svc.Repo.Setting.Set(c.Request.Context(), req.Key, req.Value); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } + oldAdultLibraryIDs := service.DecodeAllowedLibraryIDs(oldValue) + newAdultLibraryIDs := service.DecodeAllowedLibraryIDs(req.Value) + if req.Key == service.AdultLibraryIDsSettingKey && len(oldAdultLibraryIDs) == 0 && len(newAdultLibraryIDs) > 0 { + _ = svc.Repo.DB.WithContext(c.Request.Context()).Model(&model.User{}).Where("hide_adult = ?", false).Update("hide_adult", true).Error + } service.ApplyRuntimeSetting(svc.Cfg, req.Key, req.Value) if req.Key == "transcode.enabled" && !svc.Cfg.Transcoder.Enabled { svc.Transcoder.StopAll() diff --git a/internal/handler/media.go b/internal/handler/media.go index 2706577..0949cd3 100644 --- a/internal/handler/media.go +++ b/internal/handler/media.go @@ -9,6 +9,7 @@ import ( "github.com/gin-gonic/gin" + "github.com/ShukeBta/MediaStationGo/internal/middleware" "github.com/ShukeBta/MediaStationGo/internal/service" ) @@ -25,6 +26,17 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } + role, _ := c.Get(middleware.CtxUserRole) + if role != "admin" { + visibility := mediaVisibilityForRequest(c, svc) + filtered := libs[:0] + for _, lib := range libs { + if service.LibraryVisibleForUser(c.Request.Context(), svc.Repo, lib, visibility) { + filtered = append(filtered, lib) + } + } + libs = filtered + } c.JSON(http.StatusOK, libs) } } diff --git a/internal/handler/media_extra.go b/internal/handler/media_extra.go index 3e7f943..c0bb4d1 100644 --- a/internal/handler/media_extra.go +++ b/internal/handler/media_extra.go @@ -18,8 +18,8 @@ func recentMediaHandler(svc *service.Container) gin.HandlerFunc { if limit <= 0 || limit > 100 { limit = 12 } - var items []model.Media - if err := svc.Repo.DB.Order("created_at desc").Limit(limit).Find(&items).Error; err != nil { + items, err := svc.Media.SearchMediaVisible(c.Request.Context(), "", limit, mediaVisibilityForRequest(c, svc)) + if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } diff --git a/internal/handler/series.go b/internal/handler/series.go index c63ab56..92cdf35 100644 --- a/internal/handler/series.go +++ b/internal/handler/series.go @@ -25,6 +25,12 @@ type seasonGroup struct { func listSeasonsHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { libID := c.Param("id") + if lib, err := svc.Repo.Library.FindByID(c.Request.Context(), libID); err == nil && lib != nil { + if !service.LibraryVisibleForUser(c.Request.Context(), svc.Repo, *lib, mediaVisibilityForRequest(c, svc)) { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + } var rows []model.Media err := svc.Repo.DB.Where(&model.Media{LibraryID: libID}). Order("season_num asc, episode_num asc"). @@ -33,8 +39,12 @@ func listSeasonsHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } + visibility := mediaVisibilityForRequest(c, svc) buckets := make(map[int][]model.Media) for _, r := range rows { + if !visibility.Allows(&r) { + continue + } buckets[r.SeasonNum] = append(buckets[r.SeasonNum], r) } out := make([]seasonGroup, 0, len(buckets)) diff --git a/internal/handler/visibility.go b/internal/handler/visibility.go index 7a8aaa0..00b2e63 100644 --- a/internal/handler/visibility.go +++ b/internal/handler/visibility.go @@ -33,6 +33,11 @@ func mediaVisibilityForRequest(c *gin.Context, svc *service.Container) service.M } visibility.IncludeNSFW = adultEnabled && profile.AllowAdult && !userHidesAdult visibility.AllowedLibraryIDs = profileAllowedLibraryIDs(*profile) + if !visibility.IncludeNSFW { + visibility.HiddenLibraryIDs = service.AdultLibraryIDs(c.Request.Context(), svc.Repo) + } else { + visibility.HiddenLibraryIDs = nil + } return visibility } diff --git a/internal/model/model.go b/internal/model/model.go index 79e5818..73f3c0b 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -40,7 +40,7 @@ type User struct { Nickname string `gorm:"size:128" json:"nickname,omitempty"` Email string `gorm:"size:128" json:"email,omitempty"` AvatarURL string `gorm:"size:255" json:"avatar_url,omitempty"` - HideAdult bool `gorm:"default:false" json:"hide_adult"` + HideAdult bool `gorm:"default:true" json:"hide_adult"` ForcePasswordReset bool `gorm:"default:false" json:"force_password_reset"` IsActive bool `gorm:"default:true" json:"is_active"` LastLoginAt *time.Time `json:"last_login_at,omitempty"` diff --git a/internal/repository/repository.go b/internal/repository/repository.go index 46e0b5a..75fe4a3 100644 --- a/internal/repository/repository.go +++ b/internal/repository/repository.go @@ -212,12 +212,16 @@ type MediaRepository struct{ db *gorm.DB } type MediaQueryFilter struct { IncludeNSFW bool AllowedLibraryIDs []string + HiddenLibraryIDs []string } func applyMediaQueryFilter(q *gorm.DB, filter MediaQueryFilter) *gorm.DB { if !filter.IncludeNSFW { q = q.Where("nsfw = ?", false) } + if len(filter.HiddenLibraryIDs) > 0 { + q = q.Where("library_id NOT IN ?", filter.HiddenLibraryIDs) + } if len(filter.AllowedLibraryIDs) > 0 { q = q.Where("library_id IN ?", filter.AllowedLibraryIDs) } diff --git a/internal/service/auth.go b/internal/service/auth.go index 8c942af..4ab1206 100644 --- a/internal/service/auth.go +++ b/internal/service/auth.go @@ -68,6 +68,7 @@ func (s *AuthService) SeedAdmin(ctx context.Context) error { PasswordHash: hash, Role: "admin", Tier: "plus", + HideAdult: true, ForcePasswordReset: pwd == "admin123", } if err := s.repo.User.Create(ctx, user); err != nil { @@ -112,6 +113,7 @@ func (s *AuthService) Register(ctx context.Context, username, password string) ( PasswordHash: hash, Role: role, Tier: "free", + HideAdult: true, } if err := s.repo.User.Create(ctx, u); err != nil { return nil, nil, err diff --git a/internal/service/auth_user_limits_test.go b/internal/service/auth_user_limits_test.go index fce94d9..154588f 100644 --- a/internal/service/auth_user_limits_test.go +++ b/internal/service/auth_user_limits_test.go @@ -55,6 +55,17 @@ func TestRegisterRejectsMoreThanTwentyUsers(t *testing.T) { } } +func TestRegisterDefaultsAdultLibrariesHidden(t *testing.T) { + _, auth, _, _ := newAuthTestServices(t) + user, _, err := auth.Register(context.Background(), "viewer", "password") + if err != nil { + t.Fatalf("register: %v", err) + } + if !user.HideAdult { + t.Fatal("new users should hide adult libraries by default") + } +} + func TestDefaultPermissionsAreViewerOnly(t *testing.T) { perms := DefaultPermissions("user-1") if !perms.CanViewDashboard || !perms.CanPlayMedia || !perms.CanExternalPlayer { diff --git a/internal/service/emby_compat_test.go b/internal/service/emby_compat_test.go index 0b3b799..bb4cad8 100644 --- a/internal/service/emby_compat_test.go +++ b/internal/service/emby_compat_test.go @@ -175,6 +175,9 @@ func TestEmbyHidesAdultLibrariesForUserLock(t *testing.T) { if err := svc.repo.Library.Create(t.Context(), &adult); err != nil { t.Fatalf("create adult library: %v", err) } + if err := svc.repo.Setting.Set(t.Context(), AdultLibraryIDsSettingKey, `["`+adult.ID+`"]`); err != nil { + t.Fatalf("set adult libraries: %v", err) + } if err := svc.repo.DB.Create(&model.Media{LibraryID: safe.ID, Title: "安全电影", Path: `/media/movies/a.mkv`}).Error; err != nil { t.Fatalf("create safe media: %v", err) } @@ -205,7 +208,7 @@ func newTestEmbyService(t *testing.T) *EmbyService { if err != nil { t.Fatalf("open db: %v", err) } - if err := db.AutoMigrate(&model.Library{}, &model.Series{}, &model.Media{}, &model.Favorite{}, &model.PlaybackHistory{}, &model.User{}); err != nil { + if err := db.AutoMigrate(&model.Library{}, &model.Series{}, &model.Media{}, &model.Favorite{}, &model.PlaybackHistory{}, &model.User{}, &model.Setting{}); err != nil { t.Fatalf("migrate: %v", err) } repos := repository.New(db) diff --git a/internal/service/media.go b/internal/service/media.go index cd81d85..551eb4e 100644 --- a/internal/service/media.go +++ b/internal/service/media.go @@ -26,6 +26,7 @@ type MediaService struct { type MediaVisibility struct { IncludeNSFW bool AllowedLibraryIDs []string + HiddenLibraryIDs []string } func (v MediaVisibility) Allows(media *model.Media) bool { @@ -35,6 +36,11 @@ func (v MediaVisibility) Allows(media *model.Media) bool { if !v.IncludeNSFW && media.NSFW { return false } + for _, id := range v.HiddenLibraryIDs { + if id == media.LibraryID { + return false + } + } if len(v.AllowedLibraryIDs) == 0 { return true } @@ -182,6 +188,7 @@ func (s *MediaService) ListMediaVisible(ctx context.Context, libraryID string, p return s.repo.Media.ListByLibraryFiltered(ctx, libraryID, (page-1)*pageSize, pageSize, repository.MediaQueryFilter{ IncludeNSFW: visibility.IncludeNSFW, AllowedLibraryIDs: visibility.AllowedLibraryIDs, + HiddenLibraryIDs: visibility.HiddenLibraryIDs, }) } @@ -197,6 +204,7 @@ func (s *MediaService) SearchMediaVisible(ctx context.Context, query string, lim return s.repo.Media.SearchFiltered(ctx, query, limit, repository.MediaQueryFilter{ IncludeNSFW: visibility.IncludeNSFW, AllowedLibraryIDs: visibility.AllowedLibraryIDs, + HiddenLibraryIDs: visibility.HiddenLibraryIDs, }) } diff --git a/internal/service/media_visibility_test.go b/internal/service/media_visibility_test.go index d0c369b..08dd2ff 100644 --- a/internal/service/media_visibility_test.go +++ b/internal/service/media_visibility_test.go @@ -17,7 +17,7 @@ func TestMediaVisibilityFiltersNSFWAndLibraries(t *testing.T) { if err != nil { t.Fatal(err) } - if err := db.AutoMigrate(&model.Library{}, &model.Media{}); err != nil { + if err := db.AutoMigrate(&model.Library{}, &model.Media{}, &model.Setting{}); err != nil { t.Fatal(err) } repos := repository.New(db) @@ -31,6 +31,9 @@ func TestMediaVisibilityFiltersNSFWAndLibraries(t *testing.T) { if err := db.Create(&libB).Error; err != nil { t.Fatal(err) } + if err := repos.Setting.Set(t.Context(), AdultLibraryIDsSettingKey, `["`+libB.ID+`"]`); err != nil { + t.Fatal(err) + } rows := []model.Media{ {LibraryID: libA.ID, Title: "普通电影", Path: "/media/movies/a.mkv"}, {LibraryID: libA.ID, Title: "成人电影", Path: "/media/movies/b.mkv", NSFW: true}, @@ -40,11 +43,15 @@ func TestMediaVisibilityFiltersNSFWAndLibraries(t *testing.T) { t.Fatal(err) } - items, err := svc.SearchMediaVisible(t.Context(), "电影", 20, MediaVisibility{IncludeNSFW: false}) + hiddenAdultLibraries := AdultLibraryIDs(t.Context(), repos) + items, err := svc.SearchMediaVisible(t.Context(), "电影", 20, MediaVisibility{ + IncludeNSFW: false, + HiddenLibraryIDs: hiddenAdultLibraries, + }) if err != nil { t.Fatal(err) } - if got := sortedMediaTitles(items); !slices.Equal(got, []string{"普通电影", "限制媒体库电影"}) { + if got := sortedMediaTitles(items); !slices.Equal(got, []string{"普通电影"}) { t.Fatalf("NSFW-filtered search = %#v", got) } @@ -59,13 +66,27 @@ func TestMediaVisibilityFiltersNSFWAndLibraries(t *testing.T) { t.Fatalf("library-filtered search = %#v", got) } - listed, total, err := svc.ListMediaVisible(t.Context(), libA.ID, 1, 20, MediaVisibility{IncludeNSFW: false}) + listed, total, err := svc.ListMediaVisible(t.Context(), libA.ID, 1, 20, MediaVisibility{ + IncludeNSFW: false, + HiddenLibraryIDs: hiddenAdultLibraries, + }) if err != nil { t.Fatal(err) } if total != 1 || len(listed) != 1 || listed[0].Title != "普通电影" { t.Fatalf("NSFW-filtered list total=%d rows=%#v", total, sortedMediaTitles(listed)) } + + listed, total, err = svc.ListMediaVisible(t.Context(), libB.ID, 1, 20, MediaVisibility{ + IncludeNSFW: false, + HiddenLibraryIDs: hiddenAdultLibraries, + }) + if err != nil { + t.Fatal(err) + } + if total != 0 || len(listed) != 0 { + t.Fatalf("adult library should be hidden total=%d rows=%#v", total, sortedMediaTitles(listed)) + } } func sortedMediaTitles(rows []model.Media) []string { diff --git a/internal/service/visibility.go b/internal/service/visibility.go index 159aac0..1fea1e2 100644 --- a/internal/service/visibility.go +++ b/internal/service/visibility.go @@ -9,20 +9,24 @@ import ( "github.com/ShukeBta/MediaStationGo/internal/repository" ) +const AdultLibraryIDsSettingKey = "adult.library_ids" + // AdultContentEnabled reads the global Adult / NSFW switch. func AdultContentEnabled(ctx context.Context, repo *repository.Container) bool { if repo == nil || repo.Setting == nil { - return false + return true } value, err := repo.Setting.Get(ctx, "adult.enabled") if err != nil { - return false + return true } switch strings.ToLower(strings.TrimSpace(value)) { case "1", "true", "yes", "on", "enabled", "启用", "开启": return true - default: + case "0", "false", "no", "off", "disabled", "禁用", "关闭": return false + default: + return true } } @@ -45,6 +49,7 @@ func UserDefaultMediaVisibility(ctx context.Context, repo *repository.Container, if UserHidesAdult(ctx, repo, userID) { visibility.IncludeNSFW = false } + visibility.HiddenLibraryIDs = hiddenAdultLibraryIDs(ctx, repo, visibility.IncludeNSFW) if userID == "" || repo.PlayProfile == nil { return visibility } @@ -58,6 +63,7 @@ func UserDefaultMediaVisibility(ctx context.Context, repo *repository.Container, } visibility.IncludeNSFW = visibility.IncludeNSFW && row.AllowAdult visibility.AllowedLibraryIDs = DecodeAllowedLibraryIDs(row.AllowedLibraryIDs) + visibility.HiddenLibraryIDs = hiddenAdultLibraryIDs(ctx, repo, visibility.IncludeNSFW) break } return visibility @@ -99,6 +105,11 @@ func LibraryVisibleForUser(ctx context.Context, repo *repository.Container, lib if visibility.IncludeNSFW { return true } + for _, id := range visibility.HiddenLibraryIDs { + if id == lib.ID { + return false + } + } if LibraryLooksAdult(lib) { return false } @@ -127,3 +138,21 @@ func LibraryLooksAdult(lib model.Library) bool { } return false } + +func AdultLibraryIDs(ctx context.Context, repo *repository.Container) []string { + if repo == nil || repo.Setting == nil { + return nil + } + raw, err := repo.Setting.Get(ctx, AdultLibraryIDsSettingKey) + if err != nil { + return nil + } + return DecodeAllowedLibraryIDs(raw) +} + +func hiddenAdultLibraryIDs(ctx context.Context, repo *repository.Container, includeNSFW bool) []string { + if includeNSFW { + return nil + } + return AdultLibraryIDs(ctx, repo) +} diff --git a/web/src/pages/SettingsPage.tsx b/web/src/pages/SettingsPage.tsx index 9236877..2c1a69b 100644 --- a/web/src/pages/SettingsPage.tsx +++ b/web/src/pages/SettingsPage.tsx @@ -16,7 +16,7 @@ import type { Library, Setting } from '../types' interface SettingDef { key: string label: string - type: 'text' | 'select' | 'toggle' | 'number' | 'textarea' + type: 'text' | 'select' | 'toggle' | 'number' | 'textarea' | 'library-multiselect' hint?: string defaultValue?: string options?: { value: string; label: string }[] @@ -206,7 +206,15 @@ const GROUPS: SettingGroup[] = [ key: 'adult.enabled', label: '启用成人内容', type: 'toggle', - hint: '关闭后 NSFW 媒体不会出现在列表中', + hint: '全局开关。关闭后所有人都无法显示成人库;开启后用户仍默认隐藏,可在个人资料或 Bot 中自行显示。', + defaultValue: 'true', + }, + { + key: 'adult.library_ids', + label: '指定成人媒体库', + type: 'library-multiselect', + hint: '管理员指定哪些媒体库目录属于成人影视库。指定后网页、搜索和第三方客户端都会统一隐藏。', + defaultValue: '[]', }, { key: 'adult.require_pin', @@ -332,6 +340,7 @@ export function SettingsPage() { def={it} value={values[it.key] ?? it.defaultValue ?? ''} onChange={(v) => onChange(it.key, v)} + libraries={libraries} /> ))}