mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-05 21:06:38 +08:00
优化
优化
This commit is contained in:
@@ -79,3 +79,4 @@ media/
|
|||||||
tools/
|
tools/
|
||||||
verify-cache/
|
verify-cache/
|
||||||
verify-data/
|
verify-data/
|
||||||
|
.zcode/
|
||||||
@@ -3,42 +3,18 @@ package handler
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
|
||||||
|
|
||||||
"github.com/ShukeBta/MediaStationGo/internal/service"
|
"github.com/ShukeBta/MediaStationGo/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
func reclassifyMediaAfterScrape(ctx context.Context, svc *service.Container, mediaIDs ...string) int {
|
// 刮削元数据时不再自动跨库纠偏或自动创建新媒体库,保持在当前媒体库内仅更新元数据
|
||||||
return reclassifyMediaAfterScrapeWithTypeHints(ctx, svc, nil, mediaIDs...)
|
func reclassifyMediaAfterScrape(_ context.Context, _ *service.Container, _ ...string) int {
|
||||||
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func reclassifyMediaAfterScrapeWithTypeHints(ctx context.Context, svc *service.Container, mediaTypeHints map[string]string, mediaIDs ...string) int {
|
func reclassifyMediaAfterScrapeWithTypeHints(_ context.Context, _ *service.Container, _ map[string]string, _ ...string) int {
|
||||||
if svc == nil || svc.Organizer == nil {
|
return 0
|
||||||
return 0
|
|
||||||
}
|
|
||||||
res, err := svc.Organizer.ReclassifyMisclassifiedMedia(ctx, service.MediaCategoryReclassifyOptions{
|
|
||||||
MediaIDs: mediaIDs,
|
|
||||||
MediaTypeHints: mediaTypeHints,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
if svc.Log != nil {
|
|
||||||
svc.Log.Warn("scrape reclassify media failed", zap.Strings("media_ids", mediaIDs), zap.Error(err))
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return res.Reclassified
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func reclassifyLibraryAfterScrape(ctx context.Context, svc *service.Container, libraryIDs ...string) int {
|
func reclassifyLibraryAfterScrape(_ context.Context, _ *service.Container, _ ...string) int {
|
||||||
if svc == nil || svc.Organizer == nil {
|
return 0
|
||||||
return 0
|
|
||||||
}
|
|
||||||
res, err := svc.Organizer.ReclassifyMisclassifiedMedia(ctx, service.MediaCategoryReclassifyOptions{LibraryIDs: libraryIDs})
|
|
||||||
if err != nil {
|
|
||||||
if svc.Log != nil {
|
|
||||||
svc.Log.Warn("scrape reclassify library failed", zap.Strings("library_ids", libraryIDs), zap.Error(err))
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return res.Reclassified
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,32 +119,32 @@ func (c *Container) RepairAndRescrapeAllLibraries(ctx context.Context, options .
|
|||||||
result.Errors++
|
result.Errors++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
result.Matched += scrapeResult.Matched
|
result.Matched += scrapeResult.Matched
|
||||||
result.Processed += scrapeResult.Processed
|
result.Processed += scrapeResult.Processed
|
||||||
result.Errors += scrapeResult.Failed
|
result.Errors += scrapeResult.Failed
|
||||||
}
|
|
||||||
if c.Organizer != nil {
|
|
||||||
reclassifyResult, err := c.Organizer.ReclassifyMisclassifiedMedia(ctx, MediaCategoryReclassifyOptions{})
|
|
||||||
if err != nil {
|
|
||||||
return result, err
|
|
||||||
}
|
}
|
||||||
if reclassifyResult != nil {
|
if c.Organizer != nil {
|
||||||
result.Reclassified = reclassifyResult.Reclassified
|
reclassifyResult, err := c.Organizer.ReclassifyMisclassifiedMedia(ctx, MediaCategoryReclassifyOptions{})
|
||||||
result.Errors += len(reclassifyResult.Errors)
|
if err != nil {
|
||||||
c.invalidateRepairReclassifyCache(ctx, reclassifyResult.Reclassified)
|
return result, err
|
||||||
|
}
|
||||||
|
if reclassifyResult != nil {
|
||||||
|
result.Reclassified = reclassifyResult.Reclassified
|
||||||
|
result.Errors += len(reclassifyResult.Errors)
|
||||||
|
c.invalidateRepairReclassifyCache(ctx, reclassifyResult.Reclassified)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
if c.Log != nil {
|
||||||
|
c.Log.Info("repair and rescrape all libraries done",
|
||||||
|
zap.Int("repaired", result.Repaired),
|
||||||
|
zap.Int("reclassified", result.Reclassified),
|
||||||
|
zap.Int("libraries", result.Libraries),
|
||||||
|
zap.Int("matched", result.Matched),
|
||||||
|
zap.Int("processed", result.Processed),
|
||||||
|
zap.Int("errors", result.Errors))
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
}
|
}
|
||||||
if c.Log != nil {
|
|
||||||
c.Log.Info("repair and rescrape all libraries done",
|
|
||||||
zap.Int("repaired", result.Repaired),
|
|
||||||
zap.Int("reclassified", result.Reclassified),
|
|
||||||
zap.Int("libraries", result.Libraries),
|
|
||||||
zap.Int("matched", result.Matched),
|
|
||||||
zap.Int("processed", result.Processed),
|
|
||||||
zap.Int("errors", result.Errors))
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// RepairAndRescrapeLibrary 修复并重刮单个媒体库:先从该库媒体路径中的占位符
|
// RepairAndRescrapeLibrary 修复并重刮单个媒体库:先从该库媒体路径中的占位符
|
||||||
// 回填缺失/错误的外部 ID(重置相关行 scrape_status=pending),再对该库重刮
|
// 回填缺失/错误的外部 ID(重置相关行 scrape_status=pending),再对该库重刮
|
||||||
@@ -182,31 +182,31 @@ func (c *Container) RepairAndRescrapeLibrary(ctx context.Context, libraryID stri
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return result, err
|
return result, err
|
||||||
}
|
}
|
||||||
result.Matched = scrapeResult.Matched
|
result.Matched = scrapeResult.Matched
|
||||||
result.Processed = scrapeResult.Processed
|
result.Processed = scrapeResult.Processed
|
||||||
result.Errors = scrapeResult.Failed
|
result.Errors = scrapeResult.Failed
|
||||||
if c.Organizer != nil {
|
if c.Organizer != nil {
|
||||||
reclassifyResult, err := c.Organizer.ReclassifyMisclassifiedMedia(ctx, MediaCategoryReclassifyOptions{LibraryIDs: libraryIDs})
|
reclassifyResult, err := c.Organizer.ReclassifyMisclassifiedMedia(ctx, MediaCategoryReclassifyOptions{LibraryIDs: libraryIDs})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return result, err
|
return result, err
|
||||||
|
}
|
||||||
|
if reclassifyResult != nil {
|
||||||
|
result.Reclassified = reclassifyResult.Reclassified
|
||||||
|
result.Errors += len(reclassifyResult.Errors)
|
||||||
|
c.invalidateRepairReclassifyCache(ctx, reclassifyResult.Reclassified)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if reclassifyResult != nil {
|
if c.Log != nil {
|
||||||
result.Reclassified = reclassifyResult.Reclassified
|
c.Log.Info("repair and rescrape library done",
|
||||||
result.Errors += len(reclassifyResult.Errors)
|
zap.String("library", libraryID),
|
||||||
c.invalidateRepairReclassifyCache(ctx, reclassifyResult.Reclassified)
|
zap.Int("repaired", result.Repaired),
|
||||||
|
zap.Int("reclassified", result.Reclassified),
|
||||||
|
zap.Int("matched", result.Matched),
|
||||||
|
zap.Int("processed", result.Processed),
|
||||||
|
zap.Int("errors", result.Errors))
|
||||||
}
|
}
|
||||||
|
return result, nil
|
||||||
}
|
}
|
||||||
if c.Log != nil {
|
|
||||||
c.Log.Info("repair and rescrape library done",
|
|
||||||
zap.String("library", libraryID),
|
|
||||||
zap.Int("repaired", result.Repaired),
|
|
||||||
zap.Int("reclassified", result.Reclassified),
|
|
||||||
zap.Int("matched", result.Matched),
|
|
||||||
zap.Int("processed", result.Processed),
|
|
||||||
zap.Int("errors", result.Errors))
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Container) invalidateRepairReclassifyCache(ctx context.Context, changed int) {
|
func (c *Container) invalidateRepairReclassifyCache(ctx context.Context, changed int) {
|
||||||
if c == nil || c.Cache == nil || changed <= 0 {
|
if c == nil || c.Cache == nil || changed <= 0 {
|
||||||
|
|||||||
@@ -18,19 +18,10 @@ func (s *ScannerService) startAutoScrape(ctx context.Context, libraryID string)
|
|||||||
scrapeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Minute)
|
scrapeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Minute)
|
||||||
go func() {
|
go func() {
|
||||||
defer cancel()
|
defer cancel()
|
||||||
result, err := s.scraper.EnrichLibraryDetailedWithOptions(scrapeCtx, libraryID, skipEpisodeArtworkOptions(false))
|
_, err := s.scraper.EnrichLibraryDetailedWithOptions(scrapeCtx, libraryID, skipEpisodeArtworkOptions(false))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.log.Warn("scraper enrich failed", zap.Error(err))
|
s.log.Warn("scraper enrich failed", zap.Error(err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if result.Processed > 0 && s.organizer != nil {
|
|
||||||
if reclassified, err := s.organizer.ReclassifyMisclassifiedMedia(scrapeCtx, MediaCategoryReclassifyOptions{LibraryIDs: []string{libraryID}}); err != nil {
|
|
||||||
s.log.Warn("scrape auto reclassify failed", zap.String("library_id", libraryID), zap.Error(err))
|
|
||||||
} else if reclassified.Reclassified > 0 {
|
|
||||||
s.log.Info("scrape auto reclassified media",
|
|
||||||
zap.String("library_id", libraryID),
|
|
||||||
zap.Int("reclassified", reclassified.Reclassified))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|||||||
+42
-40
@@ -28,15 +28,54 @@ export interface OrganizeSource {
|
|||||||
kind: string
|
kind: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface OrganizeResultSummary {
|
||||||
|
organized?: number
|
||||||
|
skipped?: number
|
||||||
|
replaced?: number
|
||||||
|
reclassified?: number
|
||||||
|
source_path?: string
|
||||||
|
dest_path?: string
|
||||||
|
errors?: string[]
|
||||||
|
dry_run?: boolean
|
||||||
|
items?: Array<{
|
||||||
|
source: string
|
||||||
|
target?: string
|
||||||
|
action: string
|
||||||
|
reason?: string
|
||||||
|
media_type?: string
|
||||||
|
category?: string
|
||||||
|
title?: string
|
||||||
|
}>
|
||||||
|
scans?: Array<{
|
||||||
|
library_id: string
|
||||||
|
name: string
|
||||||
|
path: string
|
||||||
|
visited: number
|
||||||
|
added: number
|
||||||
|
updated: number
|
||||||
|
removed: number
|
||||||
|
error?: string
|
||||||
|
}>
|
||||||
|
scrapes?: Array<{
|
||||||
|
library_id: string
|
||||||
|
name: string
|
||||||
|
path: string
|
||||||
|
matched: number
|
||||||
|
skipped?: boolean
|
||||||
|
reason?: string
|
||||||
|
error?: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
export const toolsAPI = {
|
export const toolsAPI = {
|
||||||
organizeMedia: (mediaID: string, opts?: OrganizeOverrides) =>
|
organizeMedia: (mediaID: string, opts?: OrganizeOverrides) =>
|
||||||
api
|
api
|
||||||
.post<{ path: string }>(`/admin/media/${mediaID}/organize`, opts ?? {})
|
.post<{ path: string; scans?: unknown[]; scrapes?: unknown[] }>(`/admin/media/${mediaID}/organize`, opts ?? {})
|
||||||
.then((r) => r.data),
|
.then((r) => r.data),
|
||||||
|
|
||||||
organizeLibrary: (libraryID: string, opts?: OrganizeOverrides) =>
|
organizeLibrary: (libraryID: string, opts?: OrganizeOverrides) =>
|
||||||
api
|
api
|
||||||
.post<Record<string, unknown>>(
|
.post<OrganizeResultSummary>(
|
||||||
`/admin/libraries/${libraryID}/organize`,
|
`/admin/libraries/${libraryID}/organize`,
|
||||||
opts ?? {},
|
opts ?? {},
|
||||||
)
|
)
|
||||||
@@ -52,44 +91,7 @@ export const toolsAPI = {
|
|||||||
// into the destination with dedup + 洗版 (resolution replacement).
|
// into the destination with dedup + 洗版 (resolution replacement).
|
||||||
organizeDirectory: (opts: OrganizeOverrides) =>
|
organizeDirectory: (opts: OrganizeOverrides) =>
|
||||||
api
|
api
|
||||||
.post<{
|
.post<OrganizeResultSummary>(
|
||||||
organized: number
|
|
||||||
skipped: number
|
|
||||||
replaced?: number
|
|
||||||
reclassified?: number
|
|
||||||
source_path?: string
|
|
||||||
dest_path?: string
|
|
||||||
errors?: string[]
|
|
||||||
dry_run?: boolean
|
|
||||||
items?: Array<{
|
|
||||||
source: string
|
|
||||||
target?: string
|
|
||||||
action: string
|
|
||||||
reason?: string
|
|
||||||
media_type?: string
|
|
||||||
category?: string
|
|
||||||
title?: string
|
|
||||||
}>
|
|
||||||
scans?: Array<{
|
|
||||||
library_id: string
|
|
||||||
name: string
|
|
||||||
path: string
|
|
||||||
visited: number
|
|
||||||
added: number
|
|
||||||
updated: number
|
|
||||||
removed: number
|
|
||||||
error?: string
|
|
||||||
}>
|
|
||||||
scrapes?: Array<{
|
|
||||||
library_id: string
|
|
||||||
name: string
|
|
||||||
path: string
|
|
||||||
matched: number
|
|
||||||
skipped?: boolean
|
|
||||||
reason?: string
|
|
||||||
error?: string
|
|
||||||
}>
|
|
||||||
}>(
|
|
||||||
'/admin/organize/source',
|
'/admin/organize/source',
|
||||||
opts,
|
opts,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,381 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Folder, FolderInput, Sparkles, X, CheckCircle2, AlertCircle, RefreshCw } from 'lucide-react'
|
||||||
|
import toast from 'react-hot-toast'
|
||||||
|
|
||||||
|
import { libraryAPI } from '../api/library'
|
||||||
|
import { toolsAPI, type OrganizeResultSummary } from '../api/tools'
|
||||||
|
import type { Library } from '../types'
|
||||||
|
import { EpisodeArtworkToggle } from './EpisodeArtworkToggle'
|
||||||
|
|
||||||
|
interface ScrapeMetadataDialogProps {
|
||||||
|
open: boolean
|
||||||
|
library: Library | null
|
||||||
|
scrapeEpisodeArtwork: boolean
|
||||||
|
onScrapeEpisodeArtworkChange: (checked: boolean) => void
|
||||||
|
onClose: () => void
|
||||||
|
onCompleted?: () => void | Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScrapeTargetMode = 'current' | 'custom'
|
||||||
|
type ScrapeActionType = 'organize_and_scrape' | 'scrape_only'
|
||||||
|
|
||||||
|
export function ScrapeMetadataDialog({
|
||||||
|
open,
|
||||||
|
library,
|
||||||
|
scrapeEpisodeArtwork,
|
||||||
|
onScrapeEpisodeArtworkChange,
|
||||||
|
onClose,
|
||||||
|
onCompleted,
|
||||||
|
}: ScrapeMetadataDialogProps) {
|
||||||
|
const [targetMode, setTargetMode] = useState<ScrapeTargetMode>('current')
|
||||||
|
const [actionType, setActionType] = useState<ScrapeActionType>('organize_and_scrape')
|
||||||
|
const [destPath, setDestPath] = useState('')
|
||||||
|
const [transferMode, setTransferMode] = useState('hardlink')
|
||||||
|
const [refreshMatched, setRefreshMatched] = useState(true)
|
||||||
|
|
||||||
|
const [availableLibraries, setAvailableLibraries] = useState<Library[]>([])
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [previewBusy, setPreviewBusy] = useState(false)
|
||||||
|
const [previewResult, setPreviewResult] = useState<OrganizeResultSummary | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
setTargetMode('current')
|
||||||
|
setActionType('organize_and_scrape')
|
||||||
|
setDestPath('')
|
||||||
|
setTransferMode('hardlink')
|
||||||
|
setRefreshMatched(true)
|
||||||
|
setPreviewResult(null)
|
||||||
|
|
||||||
|
// Load available libraries for quick destination selection
|
||||||
|
libraryAPI
|
||||||
|
.list({ includeHidden: true })
|
||||||
|
.then((libs) => setAvailableLibraries(libs.filter((l) => l.id !== library?.id)))
|
||||||
|
.catch(() => undefined)
|
||||||
|
}, [open, library])
|
||||||
|
|
||||||
|
if (!open || !library) return null
|
||||||
|
|
||||||
|
const isCloud = (library.path || '').toLowerCase().startsWith('cloud://')
|
||||||
|
|
||||||
|
const effectiveDestPath = targetMode === 'custom' ? destPath.trim() : library.path
|
||||||
|
const isOrganize = targetMode === 'custom' || actionType === 'organize_and_scrape'
|
||||||
|
|
||||||
|
const handlePreview = async () => {
|
||||||
|
if (previewBusy || busy) return
|
||||||
|
if (targetMode === 'custom' && !destPath.trim()) {
|
||||||
|
toast.error('请输入指定目标文件夹路径')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setPreviewBusy(true)
|
||||||
|
setPreviewResult(null)
|
||||||
|
try {
|
||||||
|
const result = await toolsAPI.organizeLibrary(library.id, {
|
||||||
|
dest_path: effectiveDestPath,
|
||||||
|
transfer_mode: transferMode,
|
||||||
|
dry_run: true,
|
||||||
|
})
|
||||||
|
setPreviewResult(result)
|
||||||
|
toast.success('已生成整理预览')
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error || '预览失败'
|
||||||
|
toast.error(msg)
|
||||||
|
} finally {
|
||||||
|
setPreviewBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleExecute = async () => {
|
||||||
|
if (busy || previewBusy) return
|
||||||
|
|
||||||
|
if (targetMode === 'custom' && !destPath.trim()) {
|
||||||
|
toast.error('请输入指定目标文件夹路径')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
if (!isOrganize) {
|
||||||
|
// Pure metadata scrape
|
||||||
|
await libraryAPI.scrape(library.id, {
|
||||||
|
episode_images: scrapeEpisodeArtwork,
|
||||||
|
refresh_matched: refreshMatched,
|
||||||
|
})
|
||||||
|
toast.success('刮削任务已加入后台队列')
|
||||||
|
} else {
|
||||||
|
// Organize and scrape
|
||||||
|
const result = await toolsAPI.organizeLibrary(library.id, {
|
||||||
|
dest_path: effectiveDestPath,
|
||||||
|
transfer_mode: transferMode,
|
||||||
|
scan_after: true,
|
||||||
|
scrape_after: true,
|
||||||
|
})
|
||||||
|
const organized = result.organized ?? 0
|
||||||
|
const replaced = result.replaced ?? 0
|
||||||
|
const reclassified = result.reclassified ?? 0
|
||||||
|
const skipped = result.skipped ?? 0
|
||||||
|
toast.success(`整理与刮削已启动:新增 ${organized} · 替换 ${replaced} · 纠偏 ${reclassified} · 跳过 ${skipped}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
await onCompleted?.()
|
||||||
|
onClose()
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error || '操作失败'
|
||||||
|
toast.error(msg)
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-ink-900/40 px-4 py-6 backdrop-blur-sm">
|
||||||
|
<div className="flex max-h-[90vh] w-full max-w-2xl flex-col overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-2xl">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start justify-between gap-4 border-b border-gray-200 px-6 py-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-brand-50 text-brand-600">
|
||||||
|
<Sparkles size={20} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="font-display text-xl font-bold text-gray-900">刮削元数据与整理</h2>
|
||||||
|
<p className="mt-0.5 text-xs text-gray-500">
|
||||||
|
对媒体库「<span className="font-semibold text-gray-700">{library.name}</span>」执行刮削与文件整理
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} className="btn-ghost h-9 w-9 p-0 text-gray-400 hover:text-gray-600" aria-label="关闭">
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content Body */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-6 space-y-5">
|
||||||
|
{isCloud && (
|
||||||
|
<div className="flex items-start gap-2 rounded-xl border border-amber-200 bg-amber-50 p-3 text-xs font-medium text-amber-800">
|
||||||
|
<AlertCircle size={16} className="shrink-0 mt-0.5 text-amber-600" />
|
||||||
|
<span>当前为云盘媒体库,文件整理将受限。推荐仅使用元数据刮削,或在外部存储中配置云盘挂载/转移。</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mode Selection Cards */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="text-xs font-bold uppercase tracking-wider text-gray-400">选择刮削整理目标位置</label>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
{/* Option 1: Current Folder */}
|
||||||
|
<div
|
||||||
|
onClick={() => setTargetMode('current')}
|
||||||
|
className={`relative flex cursor-pointer flex-col justify-between rounded-xl border p-4 transition-all ${
|
||||||
|
targetMode === 'current'
|
||||||
|
? 'border-brand-500 bg-brand-50/40 ring-2 ring-brand-500/20'
|
||||||
|
: 'border-gray-200 bg-white hover:border-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<Folder size={18} className={targetMode === 'current' ? 'text-brand-600' : 'text-gray-500'} />
|
||||||
|
<span className="font-bold text-sm text-gray-900">媒体当前文件夹</span>
|
||||||
|
</div>
|
||||||
|
{targetMode === 'current' && <CheckCircle2 size={16} className="text-brand-600" />}
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xs text-gray-500">
|
||||||
|
直接在当前媒体库目录内进行刮削整理,无需迁移文件。
|
||||||
|
</p>
|
||||||
|
<div className="mt-3 truncate rounded-lg bg-gray-100/80 px-2.5 py-1.5 font-mono text-[11px] text-gray-600" title={library.path}>
|
||||||
|
{library.path}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Option 2: Custom Specified Folder */}
|
||||||
|
<div
|
||||||
|
onClick={() => setTargetMode('custom')}
|
||||||
|
className={`relative flex cursor-pointer flex-col justify-between rounded-xl border p-4 transition-all ${
|
||||||
|
targetMode === 'custom'
|
||||||
|
? 'border-brand-500 bg-brand-50/40 ring-2 ring-brand-500/20'
|
||||||
|
: 'border-gray-200 bg-white hover:border-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<FolderInput size={18} className={targetMode === 'custom' ? 'text-brand-600' : 'text-gray-500'} />
|
||||||
|
<span className="font-bold text-sm text-gray-900">整理到指定文件夹</span>
|
||||||
|
</div>
|
||||||
|
{targetMode === 'custom' && <CheckCircle2 size={16} className="text-brand-600" />}
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xs text-gray-500">
|
||||||
|
将媒体规范整理转移至指定目标目录,并自动扫描入库刮削。
|
||||||
|
</p>
|
||||||
|
<div className="mt-3 truncate rounded-lg bg-gray-100/80 px-2.5 py-1.5 font-mono text-[11px] text-gray-600">
|
||||||
|
{destPath.trim() || '需自定义目标路径…'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sub-options for Current Folder Mode */}
|
||||||
|
{targetMode === 'current' && (
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-gray-50/50 p-4 space-y-3">
|
||||||
|
<label className="text-xs font-bold text-gray-600">当前文件夹执行方式</label>
|
||||||
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
|
<label className={`flex cursor-pointer items-center gap-2.5 rounded-lg border p-2.5 text-xs font-semibold transition ${actionType === 'organize_and_scrape' ? 'border-brand-400 bg-brand-50 text-brand-700' : 'border-gray-200 bg-white text-gray-700'}`}>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="actionType"
|
||||||
|
checked={actionType === 'organize_and_scrape'}
|
||||||
|
onChange={() => setActionType('organize_and_scrape')}
|
||||||
|
className="text-brand-600"
|
||||||
|
/>
|
||||||
|
<span>整理规范命名并刮削</span>
|
||||||
|
</label>
|
||||||
|
<label className={`flex cursor-pointer items-center gap-2.5 rounded-lg border p-2.5 text-xs font-semibold transition ${actionType === 'scrape_only' ? 'border-brand-400 bg-brand-50 text-brand-700' : 'border-gray-200 bg-white text-gray-700'}`}>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="actionType"
|
||||||
|
checked={actionType === 'scrape_only'}
|
||||||
|
onChange={() => setActionType('scrape_only')}
|
||||||
|
className="text-brand-600"
|
||||||
|
/>
|
||||||
|
<span>仅原地刮削元数据(不重命名)</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Custom Destination Input (when targetMode === 'custom') */}
|
||||||
|
{targetMode === 'custom' && (
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-gray-50/50 p-4 space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-xs font-bold text-gray-700">
|
||||||
|
指定目标文件夹路径 <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={destPath}
|
||||||
|
onChange={(e) => setDestPath(e.target.value)}
|
||||||
|
placeholder="如 /media/organized 或 D:\Media\Movies"
|
||||||
|
className="h-10 w-full rounded-xl border border-gray-200 bg-white px-3 text-sm font-mono text-gray-800 outline-none focus:border-brand-500 focus:ring-1 focus:ring-brand-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{availableLibraries.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<span className="mb-1 block text-[11px] font-medium text-gray-500">快捷填入已有媒体库路径:</span>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{availableLibraries.map((lib) => (
|
||||||
|
<button
|
||||||
|
key={lib.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDestPath(lib.path)}
|
||||||
|
className="rounded-lg border border-gray-200 bg-white px-2.5 py-1 text-[11px] font-medium text-gray-600 hover:border-brand-300 hover:bg-brand-50 hover:text-brand-700 transition"
|
||||||
|
>
|
||||||
|
{lib.name} ({lib.path})
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Transfer Mode & Advanced Scrape Options */}
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
{isOrganize && (
|
||||||
|
<label>
|
||||||
|
<span className="mb-1 block text-xs font-bold text-gray-600">转移方式</span>
|
||||||
|
<select
|
||||||
|
value={transferMode}
|
||||||
|
onChange={(e) => setTransferMode(e.target.value)}
|
||||||
|
className="h-10 w-full rounded-xl border border-gray-200 bg-white px-3 text-xs font-semibold text-gray-700 outline-none focus:border-brand-300"
|
||||||
|
>
|
||||||
|
<option value="hardlink">硬链接(Hardlink - 推荐,不占双份空间)</option>
|
||||||
|
<option value="move">移动(Move - 转移源文件)</option>
|
||||||
|
<option value="copy">复制(Copy - 占用双倍空间)</option>
|
||||||
|
<option value="symlink">软链接(Symlink)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={`space-y-2.5 ${!isOrganize ? 'sm:col-span-2' : ''}`}>
|
||||||
|
<span className="block text-xs font-bold text-gray-600">刮削偏好设置</span>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<EpisodeArtworkToggle
|
||||||
|
checked={scrapeEpisodeArtwork}
|
||||||
|
onChange={onScrapeEpisodeArtworkChange}
|
||||||
|
title="关闭后仍会获取主海报和每集文字元数据,只跳过每集图片"
|
||||||
|
className="h-10 text-xs"
|
||||||
|
/>
|
||||||
|
<label className="flex h-10 items-center gap-2 rounded-xl border border-gray-200 bg-white px-3 text-xs font-semibold text-gray-700 cursor-pointer hover:border-gray-300">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={refreshMatched}
|
||||||
|
onChange={(e) => setRefreshMatched(e.target.checked)}
|
||||||
|
className="h-4 w-4 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
||||||
|
/>
|
||||||
|
<span>覆盖/刷新已有元数据</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Preview Results */}
|
||||||
|
{previewResult && (
|
||||||
|
<div className="rounded-xl border border-brand-200 bg-brand-50/50 p-4 text-xs space-y-1.5">
|
||||||
|
<div className="font-bold text-brand-900 flex items-center gap-1.5">
|
||||||
|
<CheckCircle2 size={14} className="text-brand-600" />
|
||||||
|
<span>整理预览结果:</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-700 flex flex-wrap gap-x-4 gap-y-1">
|
||||||
|
<span>待新增:<strong className="text-green-600">{previewResult.organized ?? 0}</strong></span>
|
||||||
|
<span>替换/洗版:<strong>{previewResult.replaced ?? 0}</strong></span>
|
||||||
|
<span>纠偏:<strong>{previewResult.reclassified ?? 0}</strong></span>
|
||||||
|
<span>已在目标/跳过:<strong>{previewResult.skipped ?? 0}</strong></span>
|
||||||
|
</div>
|
||||||
|
{previewResult.errors && previewResult.errors.length > 0 && (
|
||||||
|
<div className="mt-2 text-red-600">
|
||||||
|
<span className="font-semibold">部分异常 ({previewResult.errors.length}):</span>
|
||||||
|
<ul className="list-disc list-inside mt-0.5 space-y-0.5 font-mono text-[11px]">
|
||||||
|
{previewResult.errors.slice(0, 3).map((err, i) => (
|
||||||
|
<li key={i} className="truncate">{err}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer Actions */}
|
||||||
|
<div className="flex items-center justify-between border-t border-gray-200 px-6 py-4 bg-gray-50">
|
||||||
|
<div>
|
||||||
|
{isOrganize && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handlePreview}
|
||||||
|
disabled={previewBusy || busy || isCloud}
|
||||||
|
className="btn-outline px-4 text-xs h-9"
|
||||||
|
>
|
||||||
|
{previewBusy ? '预览生成中…' : '预览整理路径'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<button type="button" onClick={onClose} className="btn-outline px-4 text-xs h-9">
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleExecute}
|
||||||
|
disabled={busy || previewBusy || (isCloud && targetMode === 'custom')}
|
||||||
|
className="btn-primary px-5 text-xs h-9 gap-1.5"
|
||||||
|
>
|
||||||
|
{busy ? <RefreshCw size={14} className="animate-spin" /> : <Sparkles size={14} />}
|
||||||
|
<span>{busy ? '任务提交中…' : isOrganize ? '开始刮削整理' : '开始刮削元数据'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ export function LibraryPage() {
|
|||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const role = useAuthStore((s) => s.user?.role)
|
const role = useAuthStore((s) => s.user?.role)
|
||||||
|
|
||||||
|
const [scrapeDialogOpen, setScrapeDialogOpen] = useState(false)
|
||||||
const [manualSeriesScrapeOpen, setManualSeriesScrapeOpen] = useState(false)
|
const [manualSeriesScrapeOpen, setManualSeriesScrapeOpen] = useState(false)
|
||||||
const [seriesMetadataEditOpen, setSeriesMetadataEditOpen] = useState(false)
|
const [seriesMetadataEditOpen, setSeriesMetadataEditOpen] = useState(false)
|
||||||
const [manualMovie, setManualMovie] = useState<Media | null>(null)
|
const [manualMovie, setManualMovie] = useState<Media | null>(null)
|
||||||
@@ -81,7 +82,6 @@ export function LibraryPage() {
|
|||||||
repairing,
|
repairing,
|
||||||
seriesToolBusy,
|
seriesToolBusy,
|
||||||
setScrapeEpisodeArtwork,
|
setScrapeEpisodeArtwork,
|
||||||
handleScrape,
|
|
||||||
handleRepairRescrape,
|
handleRepairRescrape,
|
||||||
handleSeriesSmartScrape,
|
handleSeriesSmartScrape,
|
||||||
handleSeriesProbe,
|
handleSeriesProbe,
|
||||||
@@ -125,7 +125,7 @@ export function LibraryPage() {
|
|||||||
repairing={repairing}
|
repairing={repairing}
|
||||||
onScrapeEpisodeArtworkChange={setScrapeEpisodeArtwork}
|
onScrapeEpisodeArtworkChange={setScrapeEpisodeArtwork}
|
||||||
onScan={handleScan}
|
onScan={handleScan}
|
||||||
onScrape={handleScrape}
|
onScrape={() => setScrapeDialogOpen(true)}
|
||||||
onRepairRescrape={handleRepairRescrape}
|
onRepairRescrape={handleRepairRescrape}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -161,6 +161,8 @@ export function LibraryPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<LibraryPageDialogs
|
<LibraryPageDialogs
|
||||||
|
scrapeDialogOpen={scrapeDialogOpen}
|
||||||
|
library={library}
|
||||||
manualSeriesScrapeOpen={manualSeriesScrapeOpen}
|
manualSeriesScrapeOpen={manualSeriesScrapeOpen}
|
||||||
seriesMetadataEditOpen={seriesMetadataEditOpen}
|
seriesMetadataEditOpen={seriesMetadataEditOpen}
|
||||||
manualMovie={manualMovie}
|
manualMovie={manualMovie}
|
||||||
@@ -168,6 +170,8 @@ export function LibraryPage() {
|
|||||||
selectedSeriesMediaIDs={selectedSeriesMediaIDs}
|
selectedSeriesMediaIDs={selectedSeriesMediaIDs}
|
||||||
libraryType={library?.type}
|
libraryType={library?.type}
|
||||||
scrapeEpisodeArtwork={scrapeEpisodeArtwork}
|
scrapeEpisodeArtwork={scrapeEpisodeArtwork}
|
||||||
|
onScrapeEpisodeArtworkChange={setScrapeEpisodeArtwork}
|
||||||
|
onCloseScrapeDialog={() => setScrapeDialogOpen(false)}
|
||||||
onCloseManualSeriesScrape={() => setManualSeriesScrapeOpen(false)}
|
onCloseManualSeriesScrape={() => setManualSeriesScrapeOpen(false)}
|
||||||
onCloseSeriesMetadataEdit={() => setSeriesMetadataEditOpen(false)}
|
onCloseSeriesMetadataEdit={() => setSeriesMetadataEditOpen(false)}
|
||||||
onCloseManualMovie={() => setManualMovie(null)}
|
onCloseManualMovie={() => setManualMovie(null)}
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { ManualScrapeDialog } from '../components/ManualScrapeDialog'
|
import { ManualScrapeDialog } from '../components/ManualScrapeDialog'
|
||||||
import { MetadataEditDialog } from '../components/MetadataEditDialog'
|
import { MetadataEditDialog } from '../components/MetadataEditDialog'
|
||||||
import type { Media } from '../types'
|
import { ScrapeMetadataDialog } from '../components/ScrapeMetadataDialog'
|
||||||
|
import type { Library, Media } from '../types'
|
||||||
import { seriesTitle, type SeriesCard } from '../utils/groupSeries'
|
import { seriesTitle, type SeriesCard } from '../utils/groupSeries'
|
||||||
|
|
||||||
type LibraryPageDialogsProps = {
|
type LibraryPageDialogsProps = {
|
||||||
|
scrapeDialogOpen: boolean
|
||||||
|
library: Library | null
|
||||||
manualSeriesScrapeOpen: boolean
|
manualSeriesScrapeOpen: boolean
|
||||||
seriesMetadataEditOpen: boolean
|
seriesMetadataEditOpen: boolean
|
||||||
manualMovie: Media | null
|
manualMovie: Media | null
|
||||||
@@ -11,6 +14,8 @@ type LibraryPageDialogsProps = {
|
|||||||
selectedSeriesMediaIDs: string[]
|
selectedSeriesMediaIDs: string[]
|
||||||
libraryType?: string
|
libraryType?: string
|
||||||
scrapeEpisodeArtwork: boolean
|
scrapeEpisodeArtwork: boolean
|
||||||
|
onScrapeEpisodeArtworkChange: (checked: boolean) => void
|
||||||
|
onCloseScrapeDialog: () => void
|
||||||
onCloseManualSeriesScrape: () => void
|
onCloseManualSeriesScrape: () => void
|
||||||
onCloseSeriesMetadataEdit: () => void
|
onCloseSeriesMetadataEdit: () => void
|
||||||
onCloseManualMovie: () => void
|
onCloseManualMovie: () => void
|
||||||
@@ -18,6 +23,8 @@ type LibraryPageDialogsProps = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function LibraryPageDialogs({
|
export function LibraryPageDialogs({
|
||||||
|
scrapeDialogOpen,
|
||||||
|
library,
|
||||||
manualSeriesScrapeOpen,
|
manualSeriesScrapeOpen,
|
||||||
seriesMetadataEditOpen,
|
seriesMetadataEditOpen,
|
||||||
manualMovie,
|
manualMovie,
|
||||||
@@ -25,6 +32,8 @@ export function LibraryPageDialogs({
|
|||||||
selectedSeriesMediaIDs,
|
selectedSeriesMediaIDs,
|
||||||
libraryType,
|
libraryType,
|
||||||
scrapeEpisodeArtwork,
|
scrapeEpisodeArtwork,
|
||||||
|
onScrapeEpisodeArtworkChange,
|
||||||
|
onCloseScrapeDialog,
|
||||||
onCloseManualSeriesScrape,
|
onCloseManualSeriesScrape,
|
||||||
onCloseSeriesMetadataEdit,
|
onCloseSeriesMetadataEdit,
|
||||||
onCloseManualMovie,
|
onCloseManualMovie,
|
||||||
@@ -34,6 +43,14 @@ export function LibraryPageDialogs({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<ScrapeMetadataDialog
|
||||||
|
open={scrapeDialogOpen}
|
||||||
|
library={library}
|
||||||
|
scrapeEpisodeArtwork={scrapeEpisodeArtwork}
|
||||||
|
onScrapeEpisodeArtworkChange={onScrapeEpisodeArtworkChange}
|
||||||
|
onClose={onCloseScrapeDialog}
|
||||||
|
onCompleted={onApplied}
|
||||||
|
/>
|
||||||
<ManualScrapeDialog
|
<ManualScrapeDialog
|
||||||
open={manualSeriesScrapeOpen}
|
open={manualSeriesScrapeOpen}
|
||||||
media={selectedSeries?.rep ?? null}
|
media={selectedSeries?.rep ?? null}
|
||||||
|
|||||||
Reference in New Issue
Block a user