From 06b25564eacea38ec245d1311d49e1ec6cf2d516 Mon Sep 17 00:00:00 2001 From: ShukeBta <272197458+ShukeBta@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:17:52 +0800 Subject: [PATCH] Add library covers and configurable footer visibility --- internal/handler/handler.go | 1 + internal/handler/media.go | 37 ++++++++++++++--- internal/handler/public_ui.go | 23 +++++++++++ internal/handler/public_ui_test.go | 40 +++++++++++++++++++ internal/handler/routes_authenticated_core.go | 1 + internal/model/library_media.go | 11 ++--- internal/service/media_library_roots.go | 16 +++++++- internal/service/media_library_roots_test.go | 24 +++++++++++ web/src/api/library.ts | 7 +++- web/src/api/public.ts | 9 +++++ web/src/components/AppFooter.tsx | 12 ++++++ web/src/pages/AdminLibraryPanel.tsx | 4 ++ web/src/pages/AdminLibraryPanelSections.tsx | 10 +++++ web/src/pages/AdminLibraryTable.tsx | 19 +++++++-- web/src/pages/LibrariesPageSections.tsx | 6 ++- web/src/pages/settingsGroupGeneral.ts | 7 ++++ web/src/pages/useAdminLibraryPanel.ts | 25 +++++++++++- web/src/types/library.ts | 1 + 18 files changed, 232 insertions(+), 21 deletions(-) create mode 100644 internal/handler/public_ui.go create mode 100644 internal/handler/public_ui_test.go create mode 100644 web/src/api/public.ts diff --git a/internal/handler/handler.go b/internal/handler/handler.go index 5e1acdf..57a1b1f 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -18,6 +18,7 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C { api.GET("/health", healthCheck) api.GET("/version", versionInfo) + api.GET("/public/ui-config", publicUIConfigHandler(svc)) // Telegram Bot webhook — called by Telegram servers, no auth. api.POST("/telegram/webhook", telegramWebhookHandler(svc)) diff --git a/internal/handler/media.go b/internal/handler/media.go index 629f665..38c22f5 100644 --- a/internal/handler/media.go +++ b/internal/handler/media.go @@ -16,11 +16,12 @@ import ( ) type createLibraryReq struct { - Name string `json:"name" binding:"required"` - Path string `json:"path"` - Paths []string `json:"paths"` - Roots []service.LibraryRootInput `json:"roots"` - Type string `json:"type"` + Name string `json:"name" binding:"required"` + Path string `json:"path"` + Paths []string `json:"paths"` + Roots []service.LibraryRootInput `json:"roots"` + Type string `json:"type"` + CoverURL string `json:"cover_url"` } func listLibrariesHandler(svc *service.Container) gin.HandlerFunc { @@ -98,7 +99,7 @@ func createLibraryHandler(svc *service.Container) gin.HandlerFunc { if len(roots) == 0 && strings.TrimSpace(req.Path) != "" { roots = append(roots, service.LibraryRootInput{Path: req.Path}) } - l, err := svc.Media.CreateLibraryWithRoots(c.Request.Context(), req.Name, req.Type, roots) + l, err := svc.Media.CreateLibraryWithRootsAndCover(c.Request.Context(), req.Name, req.Type, req.CoverURL, roots) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -111,6 +112,30 @@ func createLibraryHandler(svc *service.Container) gin.HandlerFunc { } } +type updateLibraryReq struct { + CoverURL string `json:"cover_url"` +} + +func updateLibraryHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + var req updateLibraryReq + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if err := svc.Media.UpdateLibraryCover(c.Request.Context(), c.Param("id"), req.CoverURL); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + lib, err := svc.Repo.Library.FindByID(c.Request.Context(), c.Param("id")) + if err != nil || lib == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "library not found"}) + return + } + c.JSON(http.StatusOK, lib) + } +} + func deleteLibraryHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { id := c.Param("id") diff --git a/internal/handler/public_ui.go b/internal/handler/public_ui.go new file mode 100644 index 0000000..a53d377 --- /dev/null +++ b/internal/handler/public_ui.go @@ -0,0 +1,23 @@ +package handler + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "github.com/ShukeBta/MediaStationGo/internal/service" +) + +const hideCommunityLinksSettingKey = "ui.hide_community_links_for_users" + +func publicUIConfigHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + hide := false + if svc != nil && svc.Repo != nil && svc.Repo.Setting != nil { + if value, err := svc.Repo.Setting.Get(c.Request.Context(), hideCommunityLinksSettingKey); err == nil { + hide = value == "true" || value == "1" + } + } + c.JSON(http.StatusOK, gin.H{"hide_community_links_for_users": hide}) + } +} diff --git a/internal/handler/public_ui_test.go b/internal/handler/public_ui_test.go new file mode 100644 index 0000000..e6eb9ee --- /dev/null +++ b/internal/handler/public_ui_test.go @@ -0,0 +1,40 @@ +package handler + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/glebarez/sqlite" + "go.uber.org/zap" + "gorm.io/gorm" + + "github.com/ShukeBta/MediaStationGo/internal/model" + "github.com/ShukeBta/MediaStationGo/internal/repository" + "github.com/ShukeBta/MediaStationGo/internal/service" +) + +func TestPublicUIConfigExposesCommunityLinkVisibility(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.Setting{}); err != nil { + t.Fatal(err) + } + repos := repository.New(db) + if err := repos.Setting.Set(t.Context(), hideCommunityLinksSettingKey, "true"); err != nil { + t.Fatal(err) + } + svc := &service.Container{Repo: repos, Log: zap.NewNop()} + r := gin.New() + r.GET("/api/public/ui-config", publicUIConfigHandler(svc)) + req := httptest.NewRequest(http.MethodGet, "/api/public/ui-config", nil) + resp := httptest.NewRecorder() + r.ServeHTTP(resp, req) + if resp.Code != http.StatusOK || !strings.Contains(resp.Body.String(), `"hide_community_links_for_users":true`) { + t.Fatalf("status=%d body=%s", resp.Code, resp.Body.String()) + } +} diff --git a/internal/handler/routes_authenticated_core.go b/internal/handler/routes_authenticated_core.go index 89566b2..4a0caa1 100644 --- a/internal/handler/routes_authenticated_core.go +++ b/internal/handler/routes_authenticated_core.go @@ -24,6 +24,7 @@ func registerAuthedLibraryRoutes(authed *gin.RouterGroup, svc *service.Container authed.GET("/libraries", listLibrariesHandler(svc)) authed.POST("/libraries", middleware.AdminRequired(), createLibraryHandler(svc)) authed.GET("/libraries/:id", getLibraryHandler(svc)) + authed.PATCH("/libraries/:id", middleware.AdminRequired(), updateLibraryHandler(svc)) authed.DELETE("/libraries/:id", middleware.AdminRequired(), deleteLibraryHandler(svc)) authed.GET("/libraries/:id/roots", middleware.AdminRequired(), listLibraryRootsHandler(svc)) authed.POST("/libraries/:id/roots", middleware.AdminRequired(), createLibraryRootHandler(svc)) diff --git a/internal/model/library_media.go b/internal/model/library_media.go index d373e23..a2805d7 100644 --- a/internal/model/library_media.go +++ b/internal/model/library_media.go @@ -3,11 +3,12 @@ package model // Library 表示一个逻辑媒体库。Path 保留为兼容字段,指向第一条 LibraryRoot。 type Library struct { Base - Name string `gorm:"size:128;not null" json:"name"` - Path string `gorm:"size:1024;not null" json:"path"` - Type string `gorm:"size:16;not null;default:movie" json:"type"` // movie / tv / anime / music - Enabled bool `gorm:"default:true" json:"enabled"` - Roots []LibraryRoot `gorm:"foreignKey:LibraryID" json:"roots,omitempty"` + Name string `gorm:"size:128;not null" json:"name"` + Path string `gorm:"size:1024;not null" json:"path"` + Type string `gorm:"size:16;not null;default:movie" json:"type"` // movie / tv / anime / music + CoverURL string `gorm:"size:1024" json:"cover_url,omitempty"` + Enabled bool `gorm:"default:true" json:"enabled"` + Roots []LibraryRoot `gorm:"foreignKey:LibraryID" json:"roots,omitempty"` } // LibraryRoot 是逻辑媒体库下的一条真实物理/挂载路径。 diff --git a/internal/service/media_library_roots.go b/internal/service/media_library_roots.go index 08ed5ad..abd0d4f 100644 --- a/internal/service/media_library_roots.go +++ b/internal/service/media_library_roots.go @@ -24,6 +24,10 @@ func (s *MediaService) CreateLibrary(ctx context.Context, name, path, kind strin } func (s *MediaService) CreateLibraryWithRoots(ctx context.Context, name, kind string, inputs []LibraryRootInput) (*model.Library, error) { + return s.CreateLibraryWithRootsAndCover(ctx, name, kind, "", inputs) +} + +func (s *MediaService) CreateLibraryWithRootsAndCover(ctx context.Context, name, kind, coverURL string, inputs []LibraryRootInput) (*model.Library, error) { if strings.TrimSpace(name) == "" { return nil, errors.New("name required") } @@ -35,6 +39,11 @@ func (s *MediaService) CreateLibraryWithRoots(ctx context.Context, name, kind st if existing, err := s.findLogicalLibrary(ctx, name, kind); err != nil { return nil, err } else if existing != nil { + if strings.TrimSpace(coverURL) != "" { + if err := s.UpdateLibraryCover(ctx, existing.ID, coverURL); err != nil { + return nil, err + } + } lib, err := s.appendLibraryRoots(ctx, existing, roots) if err != nil { return nil, err @@ -42,7 +51,7 @@ func (s *MediaService) CreateLibraryWithRoots(ctx context.Context, name, kind st s.invalidateMediaCache(ctx) return lib, nil } - lib := &model.Library{Name: strings.TrimSpace(name), Path: roots[0].Path, Type: kind, Enabled: true} + lib := &model.Library{Name: strings.TrimSpace(name), Path: roots[0].Path, Type: kind, CoverURL: strings.TrimSpace(coverURL), Enabled: true} if err := s.repo.Library.CreateWithRoots(ctx, lib, roots); err != nil { return nil, err } @@ -50,6 +59,11 @@ func (s *MediaService) CreateLibraryWithRoots(ctx context.Context, name, kind st return lib, nil } +func (s *MediaService) UpdateLibraryCover(ctx context.Context, libraryID, coverURL string) error { + return s.repo.DB.WithContext(ctx).Model(&model.Library{}).Where("id = ?", libraryID). + Update("cover_url", strings.TrimSpace(coverURL)).Error +} + func (s *MediaService) findLogicalLibrary(ctx context.Context, name, kind string) (*model.Library, error) { if s == nil || s.repo == nil || s.repo.Library == nil { return nil, nil diff --git a/internal/service/media_library_roots_test.go b/internal/service/media_library_roots_test.go index 1395c72..10c01b5 100644 --- a/internal/service/media_library_roots_test.go +++ b/internal/service/media_library_roots_test.go @@ -53,6 +53,30 @@ func TestCreateLibraryWithRootsAppendsToExistingLogicalLibrary(t *testing.T) { } } +func TestCreateLibraryWithRootsStoresAndUpdatesCustomCover(t *testing.T) { + db := newServiceTestDB(t, &model.Library{}, &model.LibraryRoot{}, &model.Media{}) + repos := repository.New(db) + svc := NewMediaService(&config.Config{}, zap.NewNop(), repos) + + lib, err := svc.CreateLibraryWithRootsAndCover(t.Context(), "收藏", "movie", "https://example.com/cover.jpg", []LibraryRootInput{{Path: t.TempDir()}}) + if err != nil { + t.Fatal(err) + } + if lib.CoverURL != "https://example.com/cover.jpg" { + t.Fatalf("cover_url = %q", lib.CoverURL) + } + if err := svc.UpdateLibraryCover(t.Context(), lib.ID, "https://example.com/new.jpg"); err != nil { + t.Fatal(err) + } + updated, err := repos.Library.FindByID(t.Context(), lib.ID) + if err != nil { + t.Fatal(err) + } + if updated.CoverURL != "https://example.com/new.jpg" { + t.Fatalf("updated cover_url = %q", updated.CoverURL) + } +} + func TestCreateLibraryWithRootsKeepsDifferentTypesSeparate(t *testing.T) { rootMovie := t.TempDir() rootTV := t.TempDir() diff --git a/web/src/api/library.ts b/web/src/api/library.ts index 3aadc64..1e46a1e 100644 --- a/web/src/api/library.ts +++ b/web/src/api/library.ts @@ -99,8 +99,11 @@ export const libraryAPI = { create: (name: string, path: string, type: string) => api.post('/libraries', { name, path, type }).then((r) => r.data), - createWithRoots: (name: string, type: string, roots: LibraryRootInput[]) => - api.post('/libraries', { name, type, roots }).then((r) => r.data), + createWithRoots: (name: string, type: string, roots: LibraryRootInput[], coverURL = '') => + api.post('/libraries', { name, type, roots, cover_url: coverURL }).then((r) => r.data), + + update: (id: string, payload: { cover_url: string }) => + api.patch(`/libraries/${id}`, payload).then((r) => r.data), remove: (id: string) => api.delete(`/libraries/${id}`).then((r) => r.data), diff --git a/web/src/api/public.ts b/web/src/api/public.ts new file mode 100644 index 0000000..df7a024 --- /dev/null +++ b/web/src/api/public.ts @@ -0,0 +1,9 @@ +import { api } from './client' + +export interface PublicUIConfig { + hide_community_links_for_users: boolean +} + +export const publicAPI = { + uiConfig: () => api.get('/public/ui-config').then((response) => response.data), +} diff --git a/web/src/components/AppFooter.tsx b/web/src/components/AppFooter.tsx index c5d1954..1d8150f 100644 --- a/web/src/components/AppFooter.tsx +++ b/web/src/components/AppFooter.tsx @@ -1,5 +1,9 @@ +import { useEffect, useState } from 'react' import { Github, Globe, Send } from 'lucide-react' +import { publicAPI } from '../api/public' +import { useAuthStore } from '../stores/auth' + const LINKS = [ { href: 'https://github.com/ShukeBta/MediaStationGo', icon: Github, label: '开源仓库' }, { href: 'https://github.com/ShukeBta', icon: Globe, label: '作者主页' }, @@ -7,6 +11,14 @@ const LINKS = [ ] export function AppFooter({ className = '' }: { className?: string }) { + const role = useAuthStore((state) => state.user?.role) + const [hideForUsers, setHideForUsers] = useState(false) + + useEffect(() => { + publicAPI.uiConfig().then((config) => setHideForUsers(config.hide_community_links_for_users)).catch(() => undefined) + }, []) + + if (hideForUsers && role !== 'admin') return null return (