mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-06 13:26:38 +08:00
fix: add global adult library visibility controls
This commit is contained in:
@@ -238,7 +238,7 @@ mkdir -p data cache media downloads
|
|||||||
```bash
|
```bash
|
||||||
cat > .env <<'EOF'
|
cat > .env <<'EOF'
|
||||||
# 固定版本;需要升级时改成新的 MediaStationGo-vX.Y.Z 后执行 docker compose pull && docker compose up -d
|
# 固定版本;需要升级时改成新的 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
|
MEDIASTATION_HTTP_PORT=18080
|
||||||
|
|
||||||
# 程序数据和缓存建议放在 MediaStationGo 部署目录下,便于备份和迁移。
|
# 程序数据和缓存建议放在 MediaStationGo 部署目录下,便于备份和迁移。
|
||||||
@@ -307,7 +307,7 @@ vim docker-compose.yml
|
|||||||
#
|
#
|
||||||
# 镜像版本:
|
# 镜像版本:
|
||||||
# 默认拉取 latest;如需固定版本,创建 .env 并写入:
|
# 默认拉取 latest;如需固定版本,创建 .env 并写入:
|
||||||
# MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.26
|
# MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.27
|
||||||
#
|
#
|
||||||
# 路径映射总览:
|
# 路径映射总览:
|
||||||
# /data 程序数据目录。保存 SQLite 数据库、JWT secret、系统配置等,必须持久化。
|
# /data 程序数据目录。保存 SQLite 数据库、JWT secret、系统配置等,必须持久化。
|
||||||
@@ -516,7 +516,7 @@ docker compose up -d
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cat > .env <<'EOF'
|
cat > .env <<'EOF'
|
||||||
MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.26
|
MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.27
|
||||||
MEDIASTATION_HTTP_PORT=18080
|
MEDIASTATION_HTTP_PORT=18080
|
||||||
MEDIASTATION_DATA_DIR=./data
|
MEDIASTATION_DATA_DIR=./data
|
||||||
MEDIASTATION_CACHE_DIR=./cache
|
MEDIASTATION_CACHE_DIR=./cache
|
||||||
@@ -779,26 +779,26 @@ cd MediaStationGo
|
|||||||
|
|
||||||
| 平台 | 包名示例 |
|
| 平台 | 包名示例 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Linux x86_64 | `MediaStationGo-v0.0.26-linux-amd64.tar.gz` |
|
| Linux x86_64 | `MediaStationGo-v0.0.27-linux-amd64.tar.gz` |
|
||||||
| Linux ARM64 | `MediaStationGo-v0.0.26-linux-arm64.tar.gz` |
|
| Linux ARM64 | `MediaStationGo-v0.0.27-linux-arm64.tar.gz` |
|
||||||
| Windows x86_64 | `MediaStationGo-v0.0.26-windows-amd64.zip` |
|
| Windows x86_64 | `MediaStationGo-v0.0.27-windows-amd64.zip` |
|
||||||
| macOS Intel | `MediaStationGo-v0.0.26-darwin-amd64.tar.gz` |
|
| macOS Intel | `MediaStationGo-v0.0.27-darwin-amd64.tar.gz` |
|
||||||
| macOS Apple Silicon | `MediaStationGo-v0.0.26-darwin-arm64.tar.gz` |
|
| macOS Apple Silicon | `MediaStationGo-v0.0.27-darwin-arm64.tar.gz` |
|
||||||
|
|
||||||
部署步骤:
|
部署步骤:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Linux 示例
|
# Linux 示例
|
||||||
tar -xzf MediaStationGo-v0.0.26-linux-amd64.tar.gz
|
tar -xzf MediaStationGo-v0.0.27-linux-amd64.tar.gz
|
||||||
cd MediaStationGo-v0.0.26-linux-amd64
|
cd MediaStationGo-v0.0.27-linux-amd64
|
||||||
MEDIASTATION_APP_PORT=18080 ./mediastation-go
|
MEDIASTATION_APP_PORT=18080 ./mediastation-go
|
||||||
```
|
```
|
||||||
|
|
||||||
Windows:
|
Windows:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
Expand-Archive .\MediaStationGo-v0.0.26-windows-amd64.zip
|
Expand-Archive .\MediaStationGo-v0.0.27-windows-amd64.zip
|
||||||
cd .\MediaStationGo-v0.0.26-windows-amd64
|
cd .\MediaStationGo-v0.0.27-windows-amd64
|
||||||
$env:MEDIASTATION_APP_PORT = "18080"
|
$env:MEDIASTATION_APP_PORT = "18080"
|
||||||
.\mediastation-go.exe
|
.\mediastation-go.exe
|
||||||
```
|
```
|
||||||
|
|||||||
+11
-11
@@ -235,7 +235,7 @@ mkdir -p data cache media downloads
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cat > .env <<'EOF'
|
cat > .env <<'EOF'
|
||||||
MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.26
|
MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.27
|
||||||
MEDIASTATION_HTTP_PORT=18080
|
MEDIASTATION_HTTP_PORT=18080
|
||||||
MEDIASTATION_DATA_DIR=./data
|
MEDIASTATION_DATA_DIR=./data
|
||||||
MEDIASTATION_CACHE_DIR=./cache
|
MEDIASTATION_CACHE_DIR=./cache
|
||||||
@@ -344,7 +344,7 @@ For production, pin a specific release tag instead of using `latest`. Recommende
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cat > .env <<'EOF'
|
cat > .env <<'EOF'
|
||||||
MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.26
|
MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.27
|
||||||
MEDIASTATION_HTTP_PORT=18080
|
MEDIASTATION_HTTP_PORT=18080
|
||||||
MEDIASTATION_DATA_DIR=./data
|
MEDIASTATION_DATA_DIR=./data
|
||||||
MEDIASTATION_CACHE_DIR=./cache
|
MEDIASTATION_CACHE_DIR=./cache
|
||||||
@@ -593,25 +593,25 @@ Each release provides multi-platform archives:
|
|||||||
|
|
||||||
| Platform | Package example |
|
| Platform | Package example |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Linux x86_64 | `MediaStationGo-v0.0.26-linux-amd64.tar.gz` |
|
| Linux x86_64 | `MediaStationGo-v0.0.27-linux-amd64.tar.gz` |
|
||||||
| Linux ARM64 | `MediaStationGo-v0.0.26-linux-arm64.tar.gz` |
|
| Linux ARM64 | `MediaStationGo-v0.0.27-linux-arm64.tar.gz` |
|
||||||
| Windows x86_64 | `MediaStationGo-v0.0.26-windows-amd64.zip` |
|
| Windows x86_64 | `MediaStationGo-v0.0.27-windows-amd64.zip` |
|
||||||
| macOS Intel | `MediaStationGo-v0.0.26-darwin-amd64.tar.gz` |
|
| macOS Intel | `MediaStationGo-v0.0.27-darwin-amd64.tar.gz` |
|
||||||
| macOS Apple Silicon | `MediaStationGo-v0.0.26-darwin-arm64.tar.gz` |
|
| macOS Apple Silicon | `MediaStationGo-v0.0.27-darwin-arm64.tar.gz` |
|
||||||
|
|
||||||
Linux example:
|
Linux example:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
tar -xzf MediaStationGo-v0.0.26-linux-amd64.tar.gz
|
tar -xzf MediaStationGo-v0.0.27-linux-amd64.tar.gz
|
||||||
cd MediaStationGo-v0.0.26-linux-amd64
|
cd MediaStationGo-v0.0.27-linux-amd64
|
||||||
MEDIASTATION_APP_PORT=18080 ./mediastation-go
|
MEDIASTATION_APP_PORT=18080 ./mediastation-go
|
||||||
```
|
```
|
||||||
|
|
||||||
Windows example:
|
Windows example:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
Expand-Archive .\MediaStationGo-v0.0.26-windows-amd64.zip
|
Expand-Archive .\MediaStationGo-v0.0.27-windows-amd64.zip
|
||||||
cd .\MediaStationGo-v0.0.26-windows-amd64
|
cd .\MediaStationGo-v0.0.27-windows-amd64
|
||||||
$env:MEDIASTATION_APP_PORT = "18080"
|
$env:MEDIASTATION_APP_PORT = "18080"
|
||||||
.\mediastation-go.exe
|
.\mediastation-go.exe
|
||||||
```
|
```
|
||||||
|
|||||||
+1
-1
@@ -17,7 +17,7 @@
|
|||||||
#
|
#
|
||||||
# 镜像版本:
|
# 镜像版本:
|
||||||
# 默认拉取 latest;如需固定版本,创建 .env 并写入:
|
# 默认拉取 latest;如需固定版本,创建 .env 并写入:
|
||||||
# MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.26
|
# MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.27
|
||||||
#
|
#
|
||||||
# 路径映射总览:
|
# 路径映射总览:
|
||||||
# /data 程序数据目录。保存 SQLite 数据库、JWT secret、系统配置等,必须持久化。
|
# /data 程序数据目录。保存 SQLite 数据库、JWT secret、系统配置等,必须持久化。
|
||||||
|
|||||||
@@ -182,10 +182,19 @@ func updateSettingHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
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 {
|
if err := svc.Repo.Setting.Set(c.Request.Context(), req.Key, req.Value); err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
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)
|
service.ApplyRuntimeSetting(svc.Cfg, req.Key, req.Value)
|
||||||
if req.Key == "transcode.enabled" && !svc.Cfg.Transcoder.Enabled {
|
if req.Key == "transcode.enabled" && !svc.Cfg.Transcoder.Enabled {
|
||||||
svc.Transcoder.StopAll()
|
svc.Transcoder.StopAll()
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"github.com/ShukeBta/MediaStationGo/internal/middleware"
|
||||||
"github.com/ShukeBta/MediaStationGo/internal/service"
|
"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()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
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)
|
c.JSON(http.StatusOK, libs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ func recentMediaHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
if limit <= 0 || limit > 100 {
|
if limit <= 0 || limit > 100 {
|
||||||
limit = 12
|
limit = 12
|
||||||
}
|
}
|
||||||
var items []model.Media
|
items, err := svc.Media.SearchMediaVisible(c.Request.Context(), "", limit, mediaVisibilityForRequest(c, svc))
|
||||||
if err := svc.Repo.DB.Order("created_at desc").Limit(limit).Find(&items).Error; err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,12 @@ type seasonGroup struct {
|
|||||||
func listSeasonsHandler(svc *service.Container) gin.HandlerFunc {
|
func listSeasonsHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
libID := c.Param("id")
|
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
|
var rows []model.Media
|
||||||
err := svc.Repo.DB.Where(&model.Media{LibraryID: libID}).
|
err := svc.Repo.DB.Where(&model.Media{LibraryID: libID}).
|
||||||
Order("season_num asc, episode_num asc").
|
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()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
visibility := mediaVisibilityForRequest(c, svc)
|
||||||
buckets := make(map[int][]model.Media)
|
buckets := make(map[int][]model.Media)
|
||||||
for _, r := range rows {
|
for _, r := range rows {
|
||||||
|
if !visibility.Allows(&r) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
buckets[r.SeasonNum] = append(buckets[r.SeasonNum], r)
|
buckets[r.SeasonNum] = append(buckets[r.SeasonNum], r)
|
||||||
}
|
}
|
||||||
out := make([]seasonGroup, 0, len(buckets))
|
out := make([]seasonGroup, 0, len(buckets))
|
||||||
|
|||||||
@@ -33,6 +33,11 @@ func mediaVisibilityForRequest(c *gin.Context, svc *service.Container) service.M
|
|||||||
}
|
}
|
||||||
visibility.IncludeNSFW = adultEnabled && profile.AllowAdult && !userHidesAdult
|
visibility.IncludeNSFW = adultEnabled && profile.AllowAdult && !userHidesAdult
|
||||||
visibility.AllowedLibraryIDs = profileAllowedLibraryIDs(*profile)
|
visibility.AllowedLibraryIDs = profileAllowedLibraryIDs(*profile)
|
||||||
|
if !visibility.IncludeNSFW {
|
||||||
|
visibility.HiddenLibraryIDs = service.AdultLibraryIDs(c.Request.Context(), svc.Repo)
|
||||||
|
} else {
|
||||||
|
visibility.HiddenLibraryIDs = nil
|
||||||
|
}
|
||||||
return visibility
|
return visibility
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ type User struct {
|
|||||||
Nickname string `gorm:"size:128" json:"nickname,omitempty"`
|
Nickname string `gorm:"size:128" json:"nickname,omitempty"`
|
||||||
Email string `gorm:"size:128" json:"email,omitempty"`
|
Email string `gorm:"size:128" json:"email,omitempty"`
|
||||||
AvatarURL string `gorm:"size:255" json:"avatar_url,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"`
|
ForcePasswordReset bool `gorm:"default:false" json:"force_password_reset"`
|
||||||
IsActive bool `gorm:"default:true" json:"is_active"`
|
IsActive bool `gorm:"default:true" json:"is_active"`
|
||||||
LastLoginAt *time.Time `json:"last_login_at,omitempty"`
|
LastLoginAt *time.Time `json:"last_login_at,omitempty"`
|
||||||
|
|||||||
@@ -212,12 +212,16 @@ type MediaRepository struct{ db *gorm.DB }
|
|||||||
type MediaQueryFilter struct {
|
type MediaQueryFilter struct {
|
||||||
IncludeNSFW bool
|
IncludeNSFW bool
|
||||||
AllowedLibraryIDs []string
|
AllowedLibraryIDs []string
|
||||||
|
HiddenLibraryIDs []string
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyMediaQueryFilter(q *gorm.DB, filter MediaQueryFilter) *gorm.DB {
|
func applyMediaQueryFilter(q *gorm.DB, filter MediaQueryFilter) *gorm.DB {
|
||||||
if !filter.IncludeNSFW {
|
if !filter.IncludeNSFW {
|
||||||
q = q.Where("nsfw = ?", false)
|
q = q.Where("nsfw = ?", false)
|
||||||
}
|
}
|
||||||
|
if len(filter.HiddenLibraryIDs) > 0 {
|
||||||
|
q = q.Where("library_id NOT IN ?", filter.HiddenLibraryIDs)
|
||||||
|
}
|
||||||
if len(filter.AllowedLibraryIDs) > 0 {
|
if len(filter.AllowedLibraryIDs) > 0 {
|
||||||
q = q.Where("library_id IN ?", filter.AllowedLibraryIDs)
|
q = q.Where("library_id IN ?", filter.AllowedLibraryIDs)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ func (s *AuthService) SeedAdmin(ctx context.Context) error {
|
|||||||
PasswordHash: hash,
|
PasswordHash: hash,
|
||||||
Role: "admin",
|
Role: "admin",
|
||||||
Tier: "plus",
|
Tier: "plus",
|
||||||
|
HideAdult: true,
|
||||||
ForcePasswordReset: pwd == "admin123",
|
ForcePasswordReset: pwd == "admin123",
|
||||||
}
|
}
|
||||||
if err := s.repo.User.Create(ctx, user); err != nil {
|
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,
|
PasswordHash: hash,
|
||||||
Role: role,
|
Role: role,
|
||||||
Tier: "free",
|
Tier: "free",
|
||||||
|
HideAdult: true,
|
||||||
}
|
}
|
||||||
if err := s.repo.User.Create(ctx, u); err != nil {
|
if err := s.repo.User.Create(ctx, u); err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
|
|||||||
@@ -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) {
|
func TestDefaultPermissionsAreViewerOnly(t *testing.T) {
|
||||||
perms := DefaultPermissions("user-1")
|
perms := DefaultPermissions("user-1")
|
||||||
if !perms.CanViewDashboard || !perms.CanPlayMedia || !perms.CanExternalPlayer {
|
if !perms.CanViewDashboard || !perms.CanPlayMedia || !perms.CanExternalPlayer {
|
||||||
|
|||||||
@@ -175,6 +175,9 @@ func TestEmbyHidesAdultLibrariesForUserLock(t *testing.T) {
|
|||||||
if err := svc.repo.Library.Create(t.Context(), &adult); err != nil {
|
if err := svc.repo.Library.Create(t.Context(), &adult); err != nil {
|
||||||
t.Fatalf("create adult library: %v", err)
|
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 {
|
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)
|
t.Fatalf("create safe media: %v", err)
|
||||||
}
|
}
|
||||||
@@ -205,7 +208,7 @@ func newTestEmbyService(t *testing.T) *EmbyService {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("open db: %v", err)
|
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)
|
t.Fatalf("migrate: %v", err)
|
||||||
}
|
}
|
||||||
repos := repository.New(db)
|
repos := repository.New(db)
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ type MediaService struct {
|
|||||||
type MediaVisibility struct {
|
type MediaVisibility struct {
|
||||||
IncludeNSFW bool
|
IncludeNSFW bool
|
||||||
AllowedLibraryIDs []string
|
AllowedLibraryIDs []string
|
||||||
|
HiddenLibraryIDs []string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v MediaVisibility) Allows(media *model.Media) bool {
|
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 {
|
if !v.IncludeNSFW && media.NSFW {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
for _, id := range v.HiddenLibraryIDs {
|
||||||
|
if id == media.LibraryID {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
if len(v.AllowedLibraryIDs) == 0 {
|
if len(v.AllowedLibraryIDs) == 0 {
|
||||||
return true
|
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{
|
return s.repo.Media.ListByLibraryFiltered(ctx, libraryID, (page-1)*pageSize, pageSize, repository.MediaQueryFilter{
|
||||||
IncludeNSFW: visibility.IncludeNSFW,
|
IncludeNSFW: visibility.IncludeNSFW,
|
||||||
AllowedLibraryIDs: visibility.AllowedLibraryIDs,
|
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{
|
return s.repo.Media.SearchFiltered(ctx, query, limit, repository.MediaQueryFilter{
|
||||||
IncludeNSFW: visibility.IncludeNSFW,
|
IncludeNSFW: visibility.IncludeNSFW,
|
||||||
AllowedLibraryIDs: visibility.AllowedLibraryIDs,
|
AllowedLibraryIDs: visibility.AllowedLibraryIDs,
|
||||||
|
HiddenLibraryIDs: visibility.HiddenLibraryIDs,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ func TestMediaVisibilityFiltersNSFWAndLibraries(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
repos := repository.New(db)
|
repos := repository.New(db)
|
||||||
@@ -31,6 +31,9 @@ func TestMediaVisibilityFiltersNSFWAndLibraries(t *testing.T) {
|
|||||||
if err := db.Create(&libB).Error; err != nil {
|
if err := db.Create(&libB).Error; err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
if err := repos.Setting.Set(t.Context(), AdultLibraryIDsSettingKey, `["`+libB.ID+`"]`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
rows := []model.Media{
|
rows := []model.Media{
|
||||||
{LibraryID: libA.ID, Title: "普通电影", Path: "/media/movies/a.mkv"},
|
{LibraryID: libA.ID, Title: "普通电影", Path: "/media/movies/a.mkv"},
|
||||||
{LibraryID: libA.ID, Title: "成人电影", Path: "/media/movies/b.mkv", NSFW: true},
|
{LibraryID: libA.ID, Title: "成人电影", Path: "/media/movies/b.mkv", NSFW: true},
|
||||||
@@ -40,11 +43,15 @@ func TestMediaVisibilityFiltersNSFWAndLibraries(t *testing.T) {
|
|||||||
t.Fatal(err)
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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)
|
t.Fatalf("NSFW-filtered search = %#v", got)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,13 +66,27 @@ func TestMediaVisibilityFiltersNSFWAndLibraries(t *testing.T) {
|
|||||||
t.Fatalf("library-filtered search = %#v", got)
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if total != 1 || len(listed) != 1 || listed[0].Title != "普通电影" {
|
if total != 1 || len(listed) != 1 || listed[0].Title != "普通电影" {
|
||||||
t.Fatalf("NSFW-filtered list total=%d rows=%#v", total, sortedMediaTitles(listed))
|
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 {
|
func sortedMediaTitles(rows []model.Media) []string {
|
||||||
|
|||||||
@@ -9,20 +9,24 @@ import (
|
|||||||
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const AdultLibraryIDsSettingKey = "adult.library_ids"
|
||||||
|
|
||||||
// AdultContentEnabled reads the global Adult / NSFW switch.
|
// AdultContentEnabled reads the global Adult / NSFW switch.
|
||||||
func AdultContentEnabled(ctx context.Context, repo *repository.Container) bool {
|
func AdultContentEnabled(ctx context.Context, repo *repository.Container) bool {
|
||||||
if repo == nil || repo.Setting == nil {
|
if repo == nil || repo.Setting == nil {
|
||||||
return false
|
return true
|
||||||
}
|
}
|
||||||
value, err := repo.Setting.Get(ctx, "adult.enabled")
|
value, err := repo.Setting.Get(ctx, "adult.enabled")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return true
|
||||||
}
|
}
|
||||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||||
case "1", "true", "yes", "on", "enabled", "启用", "开启":
|
case "1", "true", "yes", "on", "enabled", "启用", "开启":
|
||||||
return true
|
return true
|
||||||
default:
|
case "0", "false", "no", "off", "disabled", "禁用", "关闭":
|
||||||
return false
|
return false
|
||||||
|
default:
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,6 +49,7 @@ func UserDefaultMediaVisibility(ctx context.Context, repo *repository.Container,
|
|||||||
if UserHidesAdult(ctx, repo, userID) {
|
if UserHidesAdult(ctx, repo, userID) {
|
||||||
visibility.IncludeNSFW = false
|
visibility.IncludeNSFW = false
|
||||||
}
|
}
|
||||||
|
visibility.HiddenLibraryIDs = hiddenAdultLibraryIDs(ctx, repo, visibility.IncludeNSFW)
|
||||||
if userID == "" || repo.PlayProfile == nil {
|
if userID == "" || repo.PlayProfile == nil {
|
||||||
return visibility
|
return visibility
|
||||||
}
|
}
|
||||||
@@ -58,6 +63,7 @@ func UserDefaultMediaVisibility(ctx context.Context, repo *repository.Container,
|
|||||||
}
|
}
|
||||||
visibility.IncludeNSFW = visibility.IncludeNSFW && row.AllowAdult
|
visibility.IncludeNSFW = visibility.IncludeNSFW && row.AllowAdult
|
||||||
visibility.AllowedLibraryIDs = DecodeAllowedLibraryIDs(row.AllowedLibraryIDs)
|
visibility.AllowedLibraryIDs = DecodeAllowedLibraryIDs(row.AllowedLibraryIDs)
|
||||||
|
visibility.HiddenLibraryIDs = hiddenAdultLibraryIDs(ctx, repo, visibility.IncludeNSFW)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
return visibility
|
return visibility
|
||||||
@@ -99,6 +105,11 @@ func LibraryVisibleForUser(ctx context.Context, repo *repository.Container, lib
|
|||||||
if visibility.IncludeNSFW {
|
if visibility.IncludeNSFW {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
for _, id := range visibility.HiddenLibraryIDs {
|
||||||
|
if id == lib.ID {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
if LibraryLooksAdult(lib) {
|
if LibraryLooksAdult(lib) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -127,3 +138,21 @@ func LibraryLooksAdult(lib model.Library) bool {
|
|||||||
}
|
}
|
||||||
return false
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import type { Library, Setting } from '../types'
|
|||||||
interface SettingDef {
|
interface SettingDef {
|
||||||
key: string
|
key: string
|
||||||
label: string
|
label: string
|
||||||
type: 'text' | 'select' | 'toggle' | 'number' | 'textarea'
|
type: 'text' | 'select' | 'toggle' | 'number' | 'textarea' | 'library-multiselect'
|
||||||
hint?: string
|
hint?: string
|
||||||
defaultValue?: string
|
defaultValue?: string
|
||||||
options?: { value: string; label: string }[]
|
options?: { value: string; label: string }[]
|
||||||
@@ -206,7 +206,15 @@ const GROUPS: SettingGroup[] = [
|
|||||||
key: 'adult.enabled',
|
key: 'adult.enabled',
|
||||||
label: '启用成人内容',
|
label: '启用成人内容',
|
||||||
type: 'toggle',
|
type: 'toggle',
|
||||||
hint: '关闭后 NSFW 媒体不会出现在列表中',
|
hint: '全局开关。关闭后所有人都无法显示成人库;开启后用户仍默认隐藏,可在个人资料或 Bot 中自行显示。',
|
||||||
|
defaultValue: 'true',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'adult.library_ids',
|
||||||
|
label: '指定成人媒体库',
|
||||||
|
type: 'library-multiselect',
|
||||||
|
hint: '管理员指定哪些媒体库目录属于成人影视库。指定后网页、搜索和第三方客户端都会统一隐藏。',
|
||||||
|
defaultValue: '[]',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'adult.require_pin',
|
key: 'adult.require_pin',
|
||||||
@@ -332,6 +340,7 @@ export function SettingsPage() {
|
|||||||
def={it}
|
def={it}
|
||||||
value={values[it.key] ?? it.defaultValue ?? ''}
|
value={values[it.key] ?? it.defaultValue ?? ''}
|
||||||
onChange={(v) => onChange(it.key, v)}
|
onChange={(v) => onChange(it.key, v)}
|
||||||
|
libraries={libraries}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<div className="flex items-center justify-between pt-2">
|
<div className="flex items-center justify-between pt-2">
|
||||||
@@ -395,12 +404,21 @@ function SettingRow({
|
|||||||
def,
|
def,
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
|
libraries = [],
|
||||||
}: {
|
}: {
|
||||||
def: SettingDef
|
def: SettingDef
|
||||||
value: string
|
value: string
|
||||||
onChange: (v: string) => void
|
onChange: (v: string) => void
|
||||||
|
libraries?: Library[]
|
||||||
}) {
|
}) {
|
||||||
const toggleOn = value === 'true' || value === '1' || value === 'on'
|
const toggleOn = value === 'true' || value === '1' || value === 'on'
|
||||||
|
const selectedLibraryIDs = parseLibraryIDs(value)
|
||||||
|
const toggleLibrary = (id: string, checked: boolean) => {
|
||||||
|
const next = checked
|
||||||
|
? Array.from(new Set([...selectedLibraryIDs, id]))
|
||||||
|
: selectedLibraryIDs.filter((item) => item !== id)
|
||||||
|
onChange(JSON.stringify(next))
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div className="grid items-start gap-2 md:grid-cols-[280px_1fr]">
|
<div className="grid items-start gap-2 md:grid-cols-[280px_1fr]">
|
||||||
<label className="text-sm text-ink-100">
|
<label className="text-sm text-ink-100">
|
||||||
@@ -456,7 +474,46 @@ function SettingRow({
|
|||||||
<span className="text-sm text-ink-100">{toggleOn ? '已启用' : '已关闭'}</span>
|
<span className="text-sm text-ink-100">{toggleOn ? '已启用' : '已关闭'}</span>
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
|
{def.type === 'library-multiselect' && (
|
||||||
|
<div className="space-y-2 rounded-2xl border border-gray-200 bg-white/70 p-3">
|
||||||
|
{libraries.length === 0 && (
|
||||||
|
<div className="text-sm text-ink-50">暂无媒体库,请先到「媒体与用户 → 媒体库」添加。</div>
|
||||||
|
)}
|
||||||
|
{libraries.map((lib) => (
|
||||||
|
<label key={lib.id} className="flex cursor-pointer items-start gap-3 rounded-xl px-2 py-2 hover:bg-sand-100/50">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="mt-1 h-4 w-4 accent-primary-400"
|
||||||
|
checked={selectedLibraryIDs.includes(lib.id)}
|
||||||
|
onChange={(e) => toggleLibrary(lib.id, e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block text-sm font-medium text-ink-600">{lib.name}</span>
|
||||||
|
<span className="block truncate font-mono text-xs text-ink-50">{lib.path}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
<div className="text-xs text-sand-500">
|
||||||
|
已选择 {selectedLibraryIDs.length} 个成人媒体库;新用户默认隐藏,用户可在个人资料或 Bot 中显示/隐藏。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseLibraryIDs(raw: string): string[] {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw || '[]')
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
return parsed.map((item) => String(item).trim()).filter(Boolean)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return raw
|
||||||
|
.split(/[,\n;,]/)
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user