Add library covers and configurable footer visibility

This commit is contained in:
ShukeBta
2026-07-16 13:17:52 +08:00
parent cc056077ee
commit 06b25564ea
18 changed files with 232 additions and 21 deletions
+1
View File
@@ -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))
+31 -6
View File
@@ -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")
+23
View File
@@ -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})
}
}
+40
View File
@@ -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())
}
}
@@ -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))
+6 -5
View File
@@ -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 是逻辑媒体库下的一条真实物理/挂载路径。
+15 -1
View File
@@ -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
@@ -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()
+5 -2
View File
@@ -99,8 +99,11 @@ export const libraryAPI = {
create: (name: string, path: string, type: string) =>
api.post<Library>('/libraries', { name, path, type }).then((r) => r.data),
createWithRoots: (name: string, type: string, roots: LibraryRootInput[]) =>
api.post<Library>('/libraries', { name, type, roots }).then((r) => r.data),
createWithRoots: (name: string, type: string, roots: LibraryRootInput[], coverURL = '') =>
api.post<Library>('/libraries', { name, type, roots, cover_url: coverURL }).then((r) => r.data),
update: (id: string, payload: { cover_url: string }) =>
api.patch<Library>(`/libraries/${id}`, payload).then((r) => r.data),
remove: (id: string) => api.delete(`/libraries/${id}`).then((r) => r.data),
+9
View File
@@ -0,0 +1,9 @@
import { api } from './client'
export interface PublicUIConfig {
hide_community_links_for_users: boolean
}
export const publicAPI = {
uiConfig: () => api.get<PublicUIConfig>('/public/ui-config').then((response) => response.data),
}
+12
View File
@@ -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 (
<footer className={`flex items-center justify-center gap-1 ${className}`}>
{LINKS.map((link, i) => (
+4
View File
@@ -10,9 +10,11 @@ export function AdminLibraryPanel() {
<AdminLibraryCreateForm
name={createForm.name}
type={createForm.type}
coverURL={createForm.coverURL}
roots={createForm.roots}
onNameChange={createForm.setName}
onTypeChange={createForm.setType}
onCoverURLChange={createForm.setCoverURL}
onRootChange={createForm.updateRoot}
onAddRoot={createForm.addRoot}
onRemoveRoot={createForm.removeRoot}
@@ -28,6 +30,8 @@ export function AdminLibraryPanel() {
onRemoveRoot={rootActions.removeLibraryRoot}
onScanLibrary={libraryActions.scanLibrary}
onRemoveLibrary={libraryActions.removeLibrary}
onAddLibraryRoot={libraryActions.addLibraryRoot}
onEditLibraryCover={libraryActions.editLibraryCover}
/>
</div>
)
@@ -6,9 +6,11 @@ import type { RootDraft } from './adminLibraryPanelModel'
type CreateFormProps = {
name: string
type: string
coverURL: string
roots: RootDraft[]
onNameChange: (value: string) => void
onTypeChange: (value: string) => void
onCoverURLChange: (value: string) => void
onRootChange: (index: number, patch: Partial<RootDraft>) => void
onAddRoot: () => void
onRemoveRoot: (index: number) => void
@@ -18,9 +20,11 @@ type CreateFormProps = {
export function AdminLibraryCreateForm({
name,
type,
coverURL,
roots,
onNameChange,
onTypeChange,
onCoverURLChange,
onRootChange,
onAddRoot,
onRemoveRoot,
@@ -42,6 +46,12 @@ export function AdminLibraryCreateForm({
<option value="anime">动漫</option>
<option value="music">音乐</option>
</select>
<input
className="input-base md:col-span-2"
placeholder="自定义封面 URL(可选)"
value={coverURL}
onChange={(e) => onCoverURLChange(e.target.value)}
/>
<div className="md:col-span-4 space-y-2">
{roots.map((root, index) => (
<CreateRootRow
+16 -3
View File
@@ -1,5 +1,5 @@
import type { MouseEvent, ReactNode } from 'react'
import { MoreVertical, Power, PowerOff, RefreshCw, Save, Trash2 } from 'lucide-react'
import { Image, MoreVertical, Plus, Power, PowerOff, RefreshCw, Save, Trash2 } from 'lucide-react'
import type { Library, LibraryRoot } from '../types'
import type { RootDraft } from './adminLibraryPanelModel'
@@ -15,6 +15,8 @@ type LibraryTableProps = {
onRemoveRoot: (library: Library, root: LibraryRoot) => void
onScanLibrary: (library: Library) => void
onRemoveLibrary: (library: Library) => void
onAddLibraryRoot: (library: Library) => void
onEditLibraryCover: (library: Library) => void
}
export function AdminLibraryTable({ libs, ...actions }: LibraryTableProps) {
@@ -46,7 +48,12 @@ type LibraryTableRowProps = Omit<LibraryTableProps, 'libs'> & {
function LibraryTableRow({ library, ...actions }: LibraryTableRowProps) {
return (
<tr className="border-t border-gray-200">
<td className="py-2 pr-3 font-medium text-ink-600">{library.name}</td>
<td className="py-2 pr-3 font-medium text-ink-600">
<div className="flex items-center gap-2">
{library.cover_url && <img src={library.cover_url} alt="" className="h-10 w-8 rounded object-cover" />}
<span>{library.name}</span>
</div>
</td>
<td className="py-1.5 text-ink-100">
<LibraryRootsCell library={library} {...actions} />
</td>
@@ -170,12 +177,18 @@ function RootActionButtons({ library, root, draft, ...actions }: RootEditorProps
)
}
function LibraryActionsCell({ library, onScanLibrary, onRemoveLibrary }: LibraryTableRowProps) {
function LibraryActionsCell({ library, onScanLibrary, onRemoveLibrary, onAddLibraryRoot, onEditLibraryCover }: LibraryTableRowProps) {
return (
<ActionMenu label="媒体库操作">
<MenuButton icon={<RefreshCw size={14} />} label="扫描" onClick={() => onScanLibrary(library)}>
扫描
</MenuButton>
<MenuButton icon={<Plus size={14} />} label="添加来源" onClick={() => onAddLibraryRoot(library)}>
添加来源
</MenuButton>
<MenuButton icon={<Image size={14} />} label="自定义封面" onClick={() => onEditLibraryCover(library)}>
自定义封面
</MenuButton>
<MenuButton danger icon={<Trash2 size={14} />} label="删除" onClick={() => onRemoveLibrary(library)}>
删除
</MenuButton>
+4 -2
View File
@@ -129,7 +129,9 @@ export function LibrariesContent({ previews }: { previews: LibraryPreview[] }) {
function LibraryEntryCard({ preview }: { preview: LibraryPreview }) {
const library = preview.library
const artwork = libraryArtworkItems(preview.cards)
const artwork = library.cover_url
? [{ src: library.cover_url, version: library.updated_at }]
: libraryArtworkItems(preview.cards)
const displayPath = libraryDisplayPath(library.path)
return (
@@ -137,7 +139,7 @@ function LibraryEntryCard({ preview }: { preview: LibraryPreview }) {
to={`/library/${library.id}`}
className="group flex overflow-hidden rounded-3xl border border-sand-200 bg-white p-3 shadow-card transition-all hover:-translate-y-0.5 hover:border-brand-200 hover:shadow-card-hover"
>
<div className="grid h-24 w-36 shrink-0 grid-cols-2 gap-1 overflow-hidden rounded-2xl bg-[linear-gradient(135deg,#fff7ed,#f8fafc)]">
<div className={`grid h-24 w-36 shrink-0 gap-1 overflow-hidden rounded-2xl bg-[linear-gradient(135deg,#fff7ed,#f8fafc)] ${artwork.length > 1 ? 'grid-cols-2' : 'grid-cols-1'}`}>
{artwork.length > 0 ? (
artwork.map(({ src, version }, index) => (
<img
+7
View File
@@ -5,6 +5,13 @@ export const generalSettingsGroup: SettingGroup = {
label: '常规',
description: '语言 / 转码引擎参数(API 密钥请在管理后台 → 外部API 配置)',
items: [
{
key: 'ui.hide_community_links_for_users',
label: '对普通用户隐藏社区页脚链接',
type: 'toggle',
hint: '开启后,登录页及普通用户登录后的页脚不显示 TG 群组、开源仓库和作者主页;管理员仍可见。',
defaultValue: 'false',
},
{
key: 'tmdb.language',
label: 'TMDb 元数据语言',
+23 -2
View File
@@ -31,6 +31,7 @@ function useCreateLibraryForm(refresh: () => Promise<void>) {
const [name, setName] = useState('')
const [roots, setRoots] = useState<RootDraft[]>([emptyRootDraft()])
const [type, setType] = useState('movie')
const [coverURL, setCoverURL] = useState('')
const handleCreate = async (e: FormEvent) => {
e.preventDefault()
@@ -40,10 +41,11 @@ function useCreateLibraryForm(refresh: () => Promise<void>) {
toast.error('请至少填写一个路径')
return
}
await libraryAPI.createWithRoots(name, type, payload)
await libraryAPI.createWithRoots(name, type, payload, coverURL.trim())
toast.success('媒体库已保存')
setName('')
setRoots([emptyRootDraft()])
setCoverURL('')
await refresh()
} catch (err: unknown) {
toast.error(apiErrorMessage(err, '创建失败'))
@@ -57,9 +59,11 @@ function useCreateLibraryForm(refresh: () => Promise<void>) {
return {
name,
type,
coverURL,
roots,
setName,
setType,
setCoverURL,
updateRoot,
addRoot: () => setRoots((prev) => [...prev, emptyRootDraft()]),
removeRoot: (index: number) => setRoots((prev) => (prev.length <= 1 ? prev : prev.filter((_, i) => i !== index))),
@@ -153,5 +157,22 @@ function useLibraryActions(refresh: () => Promise<void>) {
await refresh()
}
return { scanLibrary, removeLibrary }
const addLibraryRoot = async (library: Library) => {
const path = window.prompt(`为「${library.name}」添加来源目录:`)
if (!path?.trim()) return
const name = window.prompt('来源名称(可选):') ?? ''
await libraryAPI.addRoot(library.id, { path: path.trim(), name: name.trim(), enabled: true })
toast.success('来源目录已添加')
await refresh()
}
const editLibraryCover = async (library: Library) => {
const coverURL = window.prompt('自定义封面 URL(留空可清除):', library.cover_url ?? '')
if (coverURL === null) return
await libraryAPI.update(library.id, { cover_url: coverURL.trim() })
toast.success(coverURL.trim() ? '媒体库封面已保存' : '媒体库封面已清除')
await refresh()
}
return { scanLibrary, removeLibrary, addLibraryRoot, editLibraryCover }
}
+1
View File
@@ -14,6 +14,7 @@ export interface Library {
name: string
path: string
type: string
cover_url?: string
enabled: boolean
roots?: LibraryRoot[]
created_at: string