diff --git a/internal/handler/media.go b/internal/handler/media.go index 0949cd3..bfa71e2 100644 --- a/internal/handler/media.go +++ b/internal/handler/media.go @@ -27,7 +27,8 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc { return } role, _ := c.Get(middleware.CtxUserRole) - if role != "admin" { + includeHidden := role == "admin" && (c.Query("include_hidden") == "1" || c.Query("all") == "1") + if !includeHidden { visibility := mediaVisibilityForRequest(c, svc) filtered := libs[:0] for _, lib := range libs { diff --git a/internal/handler/media_test.go b/internal/handler/media_test.go new file mode 100644 index 0000000..da8c3ee --- /dev/null +++ b/internal/handler/media_test.go @@ -0,0 +1,78 @@ +package handler + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/glebarez/sqlite" + "go.uber.org/zap" + "gorm.io/gorm" + + "github.com/ShukeBta/MediaStationGo/internal/config" + "github.com/ShukeBta/MediaStationGo/internal/middleware" + "github.com/ShukeBta/MediaStationGo/internal/model" + "github.com/ShukeBta/MediaStationGo/internal/repository" + "github.com/ShukeBta/MediaStationGo/internal/service" +) + +func TestListLibrariesHidesAdultDirectoriesUnlessAdminRequestsAll(t *testing.T) { + gin.SetMode(gin.TestMode) + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.User{}, &model.Library{}, &model.Media{}, &model.Setting{}, &model.PlayProfile{}); err != nil { + t.Fatal(err) + } + repos := repository.New(db) + viewer := &model.User{Username: "viewer", PasswordHash: "hash", Role: "admin", HideAdult: true} + if err := repos.User.Create(t.Context(), viewer); err != nil { + t.Fatal(err) + } + safe := model.Library{Name: "电影", Path: "/media/movie", Type: "movie", Enabled: true} + adult := model.Library{Name: "9KG", Path: "/media/9KG", Type: "movie", Enabled: true} + if err := repos.Library.Create(t.Context(), &safe); err != nil { + t.Fatal(err) + } + if err := repos.Library.Create(t.Context(), &adult); err != nil { + t.Fatal(err) + } + if err := repos.Setting.Set(t.Context(), service.AdultLibraryIDsSettingKey, `["`+adult.ID+`"]`); err != nil { + t.Fatal(err) + } + svc := &service.Container{ + Repo: repos, + Media: service.NewMediaService(&config.Config{}, zap.NewNop(), repos), + } + + visible := requestLibraries(t, svc, viewer.ID, "admin", "/api/libraries") + if len(visible) != 1 || visible[0].ID != safe.ID { + t.Fatalf("watching library list should hide adult directories, got %#v", visible) + } + + all := requestLibraries(t, svc, viewer.ID, "admin", "/api/libraries?include_hidden=1") + if len(all) != 2 { + t.Fatalf("admin include_hidden list should keep management access, got %#v", all) + } +} + +func requestLibraries(t *testing.T, svc *service.Container, userID, role, path string) []model.Library { + t.Helper() + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Set(middleware.CtxUserID, userID) + c.Set(middleware.CtxUserRole, role) + c.Request = httptest.NewRequest(http.MethodGet, path, nil) + listLibrariesHandler(svc)(c) + if w.Code != http.StatusOK { + t.Fatalf("GET %s status = %d body=%s", path, w.Code, w.Body.String()) + } + var libs []model.Library + if err := json.Unmarshal(w.Body.Bytes(), &libs); err != nil { + t.Fatalf("decode libraries: %v", err) + } + return libs +} diff --git a/internal/handler/stats.go b/internal/handler/stats.go index 9d3665d..ecc275a 100644 --- a/internal/handler/stats.go +++ b/internal/handler/stats.go @@ -5,7 +5,9 @@ import ( "net/http" "github.com/gin-gonic/gin" + "gorm.io/gorm" + "github.com/ShukeBta/MediaStationGo/internal/model" "github.com/ShukeBta/MediaStationGo/internal/service" ) @@ -16,6 +18,65 @@ func statsHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } + if err := applyStatsVisibility(c, svc, snap); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } c.JSON(http.StatusOK, snap) } } + +func applyStatsVisibility(c *gin.Context, svc *service.Container, snap *service.Snapshot) error { + visibility := mediaVisibilityForRequest(c, svc) + libs, err := svc.Repo.Library.List(c.Request.Context()) + if err != nil { + return err + } + var visibleLibraries int64 + for _, lib := range libs { + if service.LibraryVisibleForUser(c.Request.Context(), svc.Repo, lib, visibility) { + visibleLibraries++ + } + } + snap.Libraries = visibleLibraries + + q := applyMediaVisibilityQuery(svc.Repo.DB.WithContext(c.Request.Context()).Model(&model.Media{}), visibility) + if err := q.Count(&snap.MediaCount).Error; err != nil { + return err + } + type sumRow struct { + Size int64 + Seconds int64 + } + var sum sumRow + if err := applyMediaVisibilityQuery(svc.Repo.DB.WithContext(c.Request.Context()).Model(&model.Media{}), visibility). + Select("COALESCE(SUM(size_bytes),0) as size, COALESCE(SUM(duration_sec),0) as seconds"). + Scan(&sum).Error; err != nil { + return err + } + snap.TotalSizeBytes = sum.Size + snap.TotalSeconds = sum.Seconds + + var recent []model.Media + if err := applyMediaVisibilityQuery(svc.Repo.DB.WithContext(c.Request.Context()).Model(&model.Media{}), visibility). + Order("created_at desc"). + Limit(12). + Find(&recent).Error; err != nil { + return err + } + snap.RecentlyAdded = recent + return nil +} + +func applyMediaVisibilityQuery(q *gorm.DB, visibility service.MediaVisibility) *gorm.DB { + if !visibility.IncludeNSFW { + q = q.Where("nsfw = ?", false) + } + if len(visibility.HiddenLibraryIDs) > 0 { + q = q.Where("library_id NOT IN ?", visibility.HiddenLibraryIDs) + } + if len(visibility.AllowedLibraryIDs) > 0 { + q = q.Where("library_id IN ?", visibility.AllowedLibraryIDs) + } + return q +} diff --git a/internal/handler/stats_extra.go b/internal/handler/stats_extra.go index 0144cf1..87046b1 100644 --- a/internal/handler/stats_extra.go +++ b/internal/handler/stats_extra.go @@ -3,11 +3,11 @@ // /api/stats already returns the basic snapshot. The Vue admin // dashboard also uses: // -// /api/stats/overview — counts + total size + total seconds -// /api/stats/trend — daily play count over last N days -// /api/stats/top-content — top played media (by play count) -// /api/stats/libraries — per-library item count + size -// /api/stats/monitor — live CPU/mem/disk +// /api/stats/overview — counts + total size + total seconds +// /api/stats/trend — daily play count over last N days +// /api/stats/top-content — top played media (by play count) +// /api/stats/libraries — per-library item count + size +// /api/stats/monitor — live CPU/mem/disk package handler import ( @@ -28,6 +28,10 @@ func statsOverviewHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } + if err := applyStatsVisibility(c, svc, snap); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } c.JSON(http.StatusOK, gin.H{ "libraries": snap.Libraries, "media_count": snap.MediaCount, @@ -78,8 +82,8 @@ func statsTopContentHandler(svc *service.Container) gin.HandlerFunc { limit = 10 } type row struct { - MediaID string `json:"media_id"` - PlayCount int64 `json:"play_count"` + MediaID string `json:"media_id"` + PlayCount int64 `json:"play_count"` LastPlayed time.Time `json:"last_played"` } var rows []row @@ -98,14 +102,22 @@ func statsTopContentHandler(svc *service.Container) gin.HandlerFunc { if len(ids) > 0 { var media []model.Media _ = svc.Repo.DB.Where("id IN ?", ids).Find(&media).Error + visibility := mediaVisibilityForRequest(c, svc) for _, m := range media { + if !visibility.Allows(&m) { + continue + } mIdx[m.ID] = m } } out := make([]gin.H, 0, len(rows)) for _, r := range rows { + media, ok := mIdx[r.MediaID] + if !ok { + continue + } out = append(out, gin.H{ - "media": mIdx[r.MediaID], + "media": media, "play_count": r.PlayCount, "last_played": r.LastPlayed, }) @@ -123,12 +135,17 @@ func statsLibrariesHandler(svc *service.Container) gin.HandlerFunc { return } out := make([]gin.H, 0, len(libs)) + visibility := mediaVisibilityForRequest(c, svc) for _, l := range libs { + if !service.LibraryVisibleForUser(c.Request.Context(), svc.Repo, l, visibility) { + continue + } var count int64 var size int64 - _ = svc.Repo.DB.Model(&model.Media{}). - Where("library_id = ?", l.ID).Count(&count).Error - _ = svc.Repo.DB.Model(&model.Media{}). + _ = applyMediaVisibilityQuery(svc.Repo.DB.Model(&model.Media{}), visibility). + Where("library_id = ?", l.ID). + Count(&count).Error + _ = applyMediaVisibilityQuery(svc.Repo.DB.Model(&model.Media{}), visibility). Where("library_id = ?", l.ID). Select("COALESCE(SUM(size_bytes),0)").Row().Scan(&size) out = append(out, gin.H{ diff --git a/internal/handler/stats_test.go b/internal/handler/stats_test.go new file mode 100644 index 0000000..ec57653 --- /dev/null +++ b/internal/handler/stats_test.go @@ -0,0 +1,64 @@ +package handler + +import ( + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/glebarez/sqlite" + "gorm.io/gorm" + + "github.com/ShukeBta/MediaStationGo/internal/middleware" + "github.com/ShukeBta/MediaStationGo/internal/model" + "github.com/ShukeBta/MediaStationGo/internal/repository" + "github.com/ShukeBta/MediaStationGo/internal/service" +) + +func TestStatsSnapshotHidesAdultRecentlyAddedForUser(t *testing.T) { + gin.SetMode(gin.TestMode) + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.User{}, &model.Library{}, &model.Media{}, &model.Setting{}, &model.PlayProfile{}); err != nil { + t.Fatal(err) + } + repos := repository.New(db) + viewer := &model.User{Username: "viewer", PasswordHash: "hash", Role: "user", HideAdult: true} + if err := repos.User.Create(t.Context(), viewer); err != nil { + t.Fatal(err) + } + safe := model.Library{Name: "电影", Path: "/media/movie", Type: "movie", Enabled: true} + adult := model.Library{Name: "9KG", Path: "/media/9KG", Type: "movie", Enabled: true} + if err := repos.Library.Create(t.Context(), &safe); err != nil { + t.Fatal(err) + } + if err := repos.Library.Create(t.Context(), &adult); err != nil { + t.Fatal(err) + } + if err := repos.Setting.Set(t.Context(), service.AdultLibraryIDsSettingKey, `["`+adult.ID+`"]`); err != nil { + t.Fatal(err) + } + if err := db.Create(&model.Media{LibraryID: safe.ID, Title: "普通电影", Path: "/media/movie/a.mkv", SizeBytes: 100, DurationSec: 10}).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&model.Media{LibraryID: adult.ID, Title: "成人影片", Path: "/media/9KG/a.mkv", SizeBytes: 200, DurationSec: 20}).Error; err != nil { + t.Fatal(err) + } + svc := &service.Container{Repo: repos} + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Set(middleware.CtxUserID, viewer.ID) + c.Request = httptest.NewRequest("GET", "/api/stats", nil) + + snap := &service.Snapshot{} + if err := applyStatsVisibility(c, svc, snap); err != nil { + t.Fatalf("applyStatsVisibility: %v", err) + } + if snap.MediaCount != 1 || snap.TotalSizeBytes != 100 || snap.TotalSeconds != 10 { + t.Fatalf("stats should only include visible media, got count=%d size=%d seconds=%d", snap.MediaCount, snap.TotalSizeBytes, snap.TotalSeconds) + } + if len(snap.RecentlyAdded) != 1 || snap.RecentlyAdded[0].LibraryID != safe.ID { + t.Fatalf("recently added should hide adult library, got %#v", snap.RecentlyAdded) + } +} diff --git a/web/src/api/library.ts b/web/src/api/library.ts index c44aac8..c1b5fc6 100644 --- a/web/src/api/library.ts +++ b/web/src/api/library.ts @@ -9,7 +9,12 @@ export interface MediaPage { } export const libraryAPI = { - list: () => api.get('/libraries').then((r) => r.data), + list: (options?: { includeHidden?: boolean }) => + api + .get('/libraries', { + params: options?.includeHidden ? { include_hidden: 1 } : undefined, + }) + .then((r) => r.data), create: (name: string, path: string, type: string) => api.post('/libraries', { name, path, type }).then((r) => r.data), diff --git a/web/src/pages/AdminPage.tsx b/web/src/pages/AdminPage.tsx index f81f8e9..db076b1 100644 --- a/web/src/pages/AdminPage.tsx +++ b/web/src/pages/AdminPage.tsx @@ -60,7 +60,7 @@ function LibraryPanel() { const [path, setPath] = useState('') const [type, setType] = useState('movie') - const refresh = () => libraryAPI.list().then(setLibs) + const refresh = () => libraryAPI.list({ includeHidden: true }).then(setLibs) useEffect(() => { refresh().catch(() => undefined) }, []) diff --git a/web/src/pages/SettingsPage.tsx b/web/src/pages/SettingsPage.tsx index 2c1a69b..b8a69eb 100644 --- a/web/src/pages/SettingsPage.tsx +++ b/web/src/pages/SettingsPage.tsx @@ -249,7 +249,7 @@ export function SettingsPage() { try { const [all, libs] = await Promise.all([ adminAPI.listSettings(), - libraryAPI.list().catch(() => [] as Library[]), + libraryAPI.list({ includeHidden: true }).catch(() => [] as Library[]), ]) const idx: Record = {} for (const s of all as Setting[]) {