mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-09 06:46:37 +08:00
8
This commit is contained in:
@@ -16,12 +16,13 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type createLibraryReq struct {
|
type createLibraryReq struct {
|
||||||
Name string `json:"name" binding:"required"`
|
Name string `json:"name"`
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
Paths []string `json:"paths"`
|
Paths []string `json:"paths"`
|
||||||
Roots []service.LibraryRootInput `json:"roots"`
|
Roots []service.LibraryRootInput `json:"roots"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
CoverURL string `json:"cover_url"`
|
CoverURL string `json:"cover_url"`
|
||||||
|
CreatePerSubfolder bool `json:"create_per_subfolder"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
|
func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
@@ -88,9 +89,38 @@ func createLibraryHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(roots) == 0 && strings.TrimSpace(req.Path) != "" {
|
if len(roots) == 0 && strings.TrimSpace(req.Path) != "" {
|
||||||
roots = append(roots, service.LibraryRootInput{Path: req.Path})
|
roots = append(roots, service.LibraryRootInput{Path: req.Path})
|
||||||
|
}
|
||||||
|
var l *model.Library
|
||||||
|
if req.CreatePerSubfolder {
|
||||||
|
parent := ""
|
||||||
|
if len(roots) > 0 {
|
||||||
|
parent = roots[0].Path
|
||||||
|
} else if strings.TrimSpace(req.Path) != "" {
|
||||||
|
parent = req.Path
|
||||||
}
|
}
|
||||||
l, err := svc.Media.CreateLibraryWithRootsAndCover(c.Request.Context(), req.Name, req.Type, req.CoverURL, roots)
|
created, err := svc.Media.CreateLibrariesPerSubfolder(c.Request.Context(), parent, req.Type, req.CoverURL)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uid, _ := c.Get("ctx_user_id")
|
||||||
|
for i := range created {
|
||||||
|
lib := &created[i]
|
||||||
|
svc.Audit.Record(c.Request.Context(), toString(uid), "library.create", lib.ID, c.ClientIP(), lib.Path)
|
||||||
|
if svc.Watcher != nil {
|
||||||
|
go func() { _ = svc.Watcher.Refresh(context.Background()) }()
|
||||||
|
}
|
||||||
|
for _, root := range lib.Roots {
|
||||||
|
if root.Enabled {
|
||||||
|
queueLibraryRootScan(svc, lib.ID, root.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, gin.H{"libraries": created})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
l, err := svc.Media.CreateLibraryWithRootsAndCover(c.Request.Context(), req.Name, req.Type, req.CoverURL, roots)
|
||||||
if 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
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -59,6 +60,46 @@ func (s *MediaService) CreateLibraryWithRootsAndCover(ctx context.Context, name,
|
|||||||
return lib, nil
|
return lib, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CreateLibrariesPerSubfolder 为 parent 目录下的每个直接子目录各建一个媒体库,
|
||||||
|
// 媒体库名取子目录名,路径指向该子目录。kind 为空时按子目录名推断类型。
|
||||||
|
func (s *MediaService) CreateLibrariesPerSubfolder(ctx context.Context, parent, kind, coverURL string) ([]model.Library, error) {
|
||||||
|
parent = strings.TrimSpace(parent)
|
||||||
|
if parent == "" {
|
||||||
|
return nil, errors.New("parent path required")
|
||||||
|
}
|
||||||
|
dir, err := resolveAccessibleLibraryPath(parent)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read directory failed: %w", err)
|
||||||
|
}
|
||||||
|
subdirs := make([]string, 0, len(entries))
|
||||||
|
for _, entry := range entries {
|
||||||
|
if !entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(entry.Name(), ".") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
subdirs = append(subdirs, filepath.Join(dir, entry.Name()))
|
||||||
|
}
|
||||||
|
if len(subdirs) == 0 {
|
||||||
|
return nil, errors.New("no subfolders found")
|
||||||
|
}
|
||||||
|
created := make([]model.Library, 0, len(subdirs))
|
||||||
|
for _, subdir := range subdirs {
|
||||||
|
name := filepath.Base(subdir)
|
||||||
|
lib, err := s.CreateLibraryWithRootsAndCover(ctx, name, kind, coverURL, []LibraryRootInput{{Path: subdir}})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create library for %s: %w", subdir, err)
|
||||||
|
}
|
||||||
|
created = append(created, *lib)
|
||||||
|
}
|
||||||
|
return created, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *MediaService) UpdateLibraryCover(ctx context.Context, libraryID, coverURL string) error {
|
func (s *MediaService) UpdateLibraryCover(ctx context.Context, libraryID, coverURL string) error {
|
||||||
return s.repo.DB.WithContext(ctx).Model(&model.Library{}).Where("id = ?", libraryID).
|
return s.repo.DB.WithContext(ctx).Model(&model.Library{}).Where("id = ?", libraryID).
|
||||||
Update("cover_url", strings.TrimSpace(coverURL)).Error
|
Update("cover_url", strings.TrimSpace(coverURL)).Error
|
||||||
|
|||||||
@@ -102,6 +102,9 @@ export const libraryAPI = {
|
|||||||
createWithRoots: (name: string, type: string, roots: LibraryRootInput[], coverURL = '') =>
|
createWithRoots: (name: string, type: string, roots: LibraryRootInput[], coverURL = '') =>
|
||||||
api.post<Library>('/libraries', { name, type, roots, cover_url: coverURL }).then((r) => r.data),
|
api.post<Library>('/libraries', { name, type, roots, cover_url: coverURL }).then((r) => r.data),
|
||||||
|
|
||||||
|
createPerSubfolder: (parentPath: string, type: string, coverURL = '') =>
|
||||||
|
api.post<{ libraries: Library[] }>('/libraries', { path: parentPath, type, cover_url: coverURL, create_per_subfolder: true }).then((r) => r.data),
|
||||||
|
|
||||||
update: (id: string, payload: { cover_url: string }) =>
|
update: (id: string, payload: { cover_url: string }) =>
|
||||||
api.patch<Library>(`/libraries/${id}`, payload).then((r) => r.data),
|
api.patch<Library>(`/libraries/${id}`, payload).then((r) => r.data),
|
||||||
|
|
||||||
|
|||||||
@@ -13,9 +13,11 @@ export function AdminLibraryPanel() {
|
|||||||
type={createForm.type}
|
type={createForm.type}
|
||||||
coverURL={createForm.coverURL}
|
coverURL={createForm.coverURL}
|
||||||
roots={createForm.roots}
|
roots={createForm.roots}
|
||||||
|
createPerSubfolder={createForm.createPerSubfolder}
|
||||||
onNameChange={createForm.setName}
|
onNameChange={createForm.setName}
|
||||||
onTypeChange={createForm.setType}
|
onTypeChange={createForm.setType}
|
||||||
onCoverURLChange={createForm.setCoverURL}
|
onCoverURLChange={createForm.setCoverURL}
|
||||||
|
onCreatePerSubfolderChange={createForm.setCreatePerSubfolder}
|
||||||
onRootChange={createForm.updateRoot}
|
onRootChange={createForm.updateRoot}
|
||||||
onAddRoot={createForm.addRoot}
|
onAddRoot={createForm.addRoot}
|
||||||
onRemoveRoot={createForm.removeRoot}
|
onRemoveRoot={createForm.removeRoot}
|
||||||
|
|||||||
@@ -9,9 +9,11 @@ type CreateFormProps = {
|
|||||||
type: string
|
type: string
|
||||||
coverURL: string
|
coverURL: string
|
||||||
roots: RootDraft[]
|
roots: RootDraft[]
|
||||||
|
createPerSubfolder: boolean
|
||||||
onNameChange: (value: string) => void
|
onNameChange: (value: string) => void
|
||||||
onTypeChange: (value: string) => void
|
onTypeChange: (value: string) => void
|
||||||
onCoverURLChange: (value: string) => void
|
onCoverURLChange: (value: string) => void
|
||||||
|
onCreatePerSubfolderChange: (value: boolean) => void
|
||||||
onRootChange: (index: number, patch: Partial<RootDraft>) => void
|
onRootChange: (index: number, patch: Partial<RootDraft>) => void
|
||||||
onAddRoot: () => void
|
onAddRoot: () => void
|
||||||
onRemoveRoot: (index: number) => void
|
onRemoveRoot: (index: number) => void
|
||||||
@@ -23,9 +25,11 @@ export function AdminLibraryCreateForm({
|
|||||||
type,
|
type,
|
||||||
coverURL,
|
coverURL,
|
||||||
roots,
|
roots,
|
||||||
|
createPerSubfolder,
|
||||||
onNameChange,
|
onNameChange,
|
||||||
onTypeChange,
|
onTypeChange,
|
||||||
onCoverURLChange,
|
onCoverURLChange,
|
||||||
|
onCreatePerSubfolderChange,
|
||||||
onRootChange,
|
onRootChange,
|
||||||
onAddRoot,
|
onAddRoot,
|
||||||
onRemoveRoot,
|
onRemoveRoot,
|
||||||
@@ -50,9 +54,9 @@ export function AdminLibraryCreateForm({
|
|||||||
<>
|
<>
|
||||||
<form onSubmit={onSubmit} className="glass-panel grid gap-3 md:grid-cols-4">
|
<form onSubmit={onSubmit} className="glass-panel grid gap-3 md:grid-cols-4">
|
||||||
<input
|
<input
|
||||||
required
|
required={!createPerSubfolder}
|
||||||
className="input-base"
|
className="input-base"
|
||||||
placeholder="名称"
|
placeholder={createPerSubfolder ? '父级媒体库名(批量模式忽略)' : '名称'}
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => onNameChange(e.target.value)}
|
onChange={(e) => onNameChange(e.target.value)}
|
||||||
/>
|
/>
|
||||||
@@ -81,15 +85,31 @@ export function AdminLibraryCreateForm({
|
|||||||
onRemove={onRemoveRoot}
|
onRemove={onRemoveRoot}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<button type="button" className="inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-sm" onClick={onAddRoot}>
|
{!createPerSubfolder && (
|
||||||
<Plus size={16} /> 添加路径
|
<button type="button" className="inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-sm" onClick={onAddRoot}>
|
||||||
</button>
|
<Plus size={16} /> 添加路径
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="md:col-span-4 -mt-2 text-xs text-sand-500">
|
<p className="md:col-span-4 -mt-2 text-xs text-sand-500">
|
||||||
支持直接点选或手动输入;名称和类型与现有媒体库一致时,会自动把这里填写的路径追加到该媒体库。
|
支持直接点选或手动输入;名称和类型与现有媒体库一致时,会自动把这里填写的路径追加到该媒体库。
|
||||||
</p>
|
</p>
|
||||||
|
<label className="md:col-span-4 flex items-center gap-2 text-sm text-ink-100">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="h-4 w-4 accent-brand-400"
|
||||||
|
checked={createPerSubfolder}
|
||||||
|
onChange={(e) => onCreatePerSubfolderChange(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>按目录下每个子文件夹各建一个媒体库(媒体库名取子文件夹名)</span>
|
||||||
|
</label>
|
||||||
|
{createPerSubfolder && (
|
||||||
|
<p className="md:col-span-4 -mt-2 text-xs text-sand-500">
|
||||||
|
批处理模式:仅取上方第一个路径作为父级目录,会为其中每个子文件夹分别创建媒体库,可自选类型用于整体推断。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<button type="submit" className="neon-button md:col-span-4">
|
<button type="submit" className="neon-button md:col-span-4">
|
||||||
新建 / 追加路径
|
{createPerSubfolder ? '按目录批量创建' : '新建 / 追加路径'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
|||||||
@@ -32,20 +32,32 @@ function useCreateLibraryForm(refresh: () => Promise<void>) {
|
|||||||
const [roots, setRoots] = useState<RootDraft[]>([emptyRootDraft()])
|
const [roots, setRoots] = useState<RootDraft[]>([emptyRootDraft()])
|
||||||
const [type, setType] = useState('movie')
|
const [type, setType] = useState('movie')
|
||||||
const [coverURL, setCoverURL] = useState('')
|
const [coverURL, setCoverURL] = useState('')
|
||||||
|
const [createPerSubfolder, setCreatePerSubfolder] = useState(false)
|
||||||
|
|
||||||
const handleCreate = async (e: FormEvent) => {
|
const handleCreate = async (e: FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
try {
|
try {
|
||||||
const payload = createRootPayload(roots)
|
if (createPerSubfolder) {
|
||||||
if (payload.length === 0) {
|
const parentPath = roots[0]?.path?.trim()
|
||||||
toast.error('请至少填写一个路径')
|
if (!parentPath) {
|
||||||
return
|
toast.error('请先选择或填写父级目录')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const { libraries } = await libraryAPI.createPerSubfolder(parentPath, type, coverURL.trim())
|
||||||
|
toast.success(`已按目录创建 ${libraries.length} 个媒体库`)
|
||||||
|
} else {
|
||||||
|
const payload = createRootPayload(roots)
|
||||||
|
if (payload.length === 0) {
|
||||||
|
toast.error('请至少填写一个路径')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await libraryAPI.createWithRoots(name, type, payload, coverURL.trim())
|
||||||
|
toast.success('媒体库已保存')
|
||||||
}
|
}
|
||||||
await libraryAPI.createWithRoots(name, type, payload, coverURL.trim())
|
|
||||||
toast.success('媒体库已保存')
|
|
||||||
setName('')
|
setName('')
|
||||||
setRoots([emptyRootDraft()])
|
setRoots([emptyRootDraft()])
|
||||||
setCoverURL('')
|
setCoverURL('')
|
||||||
|
setCreatePerSubfolder(false)
|
||||||
await refresh()
|
await refresh()
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
toast.error(apiErrorMessage(err, '创建失败'))
|
toast.error(apiErrorMessage(err, '创建失败'))
|
||||||
@@ -61,9 +73,11 @@ function useCreateLibraryForm(refresh: () => Promise<void>) {
|
|||||||
type,
|
type,
|
||||||
coverURL,
|
coverURL,
|
||||||
roots,
|
roots,
|
||||||
|
createPerSubfolder,
|
||||||
setName,
|
setName,
|
||||||
setType,
|
setType,
|
||||||
setCoverURL,
|
setCoverURL,
|
||||||
|
setCreatePerSubfolder,
|
||||||
updateRoot,
|
updateRoot,
|
||||||
addRoot: () => setRoots((prev) => [...prev, emptyRootDraft()]),
|
addRoot: () => setRoots((prev) => [...prev, emptyRootDraft()]),
|
||||||
removeRoot: (index: number) => setRoots((prev) => (prev.length <= 1 ? prev : prev.filter((_, i) => i !== index))),
|
removeRoot: (index: number) => setRoots((prev) => (prev.length <= 1 ? prev : prev.filter((_, i) => i !== index))),
|
||||||
|
|||||||
Reference in New Issue
Block a user