mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 11:36:36 +08:00
优化
优化
This commit is contained in:
@@ -79,3 +79,4 @@ media/
|
||||
tools/
|
||||
verify-cache/
|
||||
verify-data/
|
||||
.zcode/
|
||||
@@ -3,42 +3,18 @@ package handler
|
||||
import (
|
||||
"context"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"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 {
|
||||
if svc == nil || svc.Organizer == nil {
|
||||
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 reclassifyMediaAfterScrapeWithTypeHints(_ context.Context, _ *service.Container, _ map[string]string, _ ...string) int {
|
||||
return 0
|
||||
}
|
||||
|
||||
func reclassifyLibraryAfterScrape(ctx context.Context, svc *service.Container, libraryIDs ...string) int {
|
||||
if svc == nil || svc.Organizer == nil {
|
||||
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
|
||||
func reclassifyLibraryAfterScrape(_ context.Context, _ *service.Container, _ ...string) int {
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -119,32 +119,32 @@ func (c *Container) RepairAndRescrapeAllLibraries(ctx context.Context, options .
|
||||
result.Errors++
|
||||
continue
|
||||
}
|
||||
result.Matched += scrapeResult.Matched
|
||||
result.Processed += scrapeResult.Processed
|
||||
result.Errors += scrapeResult.Failed
|
||||
}
|
||||
if c.Organizer != nil {
|
||||
reclassifyResult, err := c.Organizer.ReclassifyMisclassifiedMedia(ctx, MediaCategoryReclassifyOptions{})
|
||||
if err != nil {
|
||||
return result, err
|
||||
result.Matched += scrapeResult.Matched
|
||||
result.Processed += scrapeResult.Processed
|
||||
result.Errors += scrapeResult.Failed
|
||||
}
|
||||
if reclassifyResult != nil {
|
||||
result.Reclassified = reclassifyResult.Reclassified
|
||||
result.Errors += len(reclassifyResult.Errors)
|
||||
c.invalidateRepairReclassifyCache(ctx, reclassifyResult.Reclassified)
|
||||
if c.Organizer != nil {
|
||||
reclassifyResult, err := c.Organizer.ReclassifyMisclassifiedMedia(ctx, MediaCategoryReclassifyOptions{})
|
||||
if err != nil {
|
||||
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 修复并重刮单个媒体库:先从该库媒体路径中的占位符
|
||||
// 回填缺失/错误的外部 ID(重置相关行 scrape_status=pending),再对该库重刮
|
||||
@@ -182,31 +182,31 @@ func (c *Container) RepairAndRescrapeLibrary(ctx context.Context, libraryID stri
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
result.Matched = scrapeResult.Matched
|
||||
result.Processed = scrapeResult.Processed
|
||||
result.Errors = scrapeResult.Failed
|
||||
if c.Organizer != nil {
|
||||
reclassifyResult, err := c.Organizer.ReclassifyMisclassifiedMedia(ctx, MediaCategoryReclassifyOptions{LibraryIDs: libraryIDs})
|
||||
if err != nil {
|
||||
return result, err
|
||||
result.Matched = scrapeResult.Matched
|
||||
result.Processed = scrapeResult.Processed
|
||||
result.Errors = scrapeResult.Failed
|
||||
if c.Organizer != nil {
|
||||
reclassifyResult, err := c.Organizer.ReclassifyMisclassifiedMedia(ctx, MediaCategoryReclassifyOptions{LibraryIDs: libraryIDs})
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
if reclassifyResult != nil {
|
||||
result.Reclassified = reclassifyResult.Reclassified
|
||||
result.Errors += len(reclassifyResult.Errors)
|
||||
c.invalidateRepairReclassifyCache(ctx, reclassifyResult.Reclassified)
|
||||
}
|
||||
}
|
||||
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 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
|
||||
}
|
||||
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) {
|
||||
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)
|
||||
go func() {
|
||||
defer cancel()
|
||||
result, err := s.scraper.EnrichLibraryDetailedWithOptions(scrapeCtx, libraryID, skipEpisodeArtworkOptions(false))
|
||||
_, err := s.scraper.EnrichLibraryDetailedWithOptions(scrapeCtx, libraryID, skipEpisodeArtworkOptions(false))
|
||||
if err != nil {
|
||||
s.log.Warn("scraper enrich failed", zap.Error(err))
|
||||
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
|
||||
}
|
||||
|
||||
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 = {
|
||||
organizeMedia: (mediaID: string, opts?: OrganizeOverrides) =>
|
||||
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),
|
||||
|
||||
organizeLibrary: (libraryID: string, opts?: OrganizeOverrides) =>
|
||||
api
|
||||
.post<Record<string, unknown>>(
|
||||
.post<OrganizeResultSummary>(
|
||||
`/admin/libraries/${libraryID}/organize`,
|
||||
opts ?? {},
|
||||
)
|
||||
@@ -52,44 +91,7 @@ export const toolsAPI = {
|
||||
// into the destination with dedup + 洗版 (resolution replacement).
|
||||
organizeDirectory: (opts: OrganizeOverrides) =>
|
||||
api
|
||||
.post<{
|
||||
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
|
||||
}>
|
||||
}>(
|
||||
.post<OrganizeResultSummary>(
|
||||
'/admin/organize/source',
|
||||
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 role = useAuthStore((s) => s.user?.role)
|
||||
|
||||
const [scrapeDialogOpen, setScrapeDialogOpen] = useState(false)
|
||||
const [manualSeriesScrapeOpen, setManualSeriesScrapeOpen] = useState(false)
|
||||
const [seriesMetadataEditOpen, setSeriesMetadataEditOpen] = useState(false)
|
||||
const [manualMovie, setManualMovie] = useState<Media | null>(null)
|
||||
@@ -81,7 +82,6 @@ export function LibraryPage() {
|
||||
repairing,
|
||||
seriesToolBusy,
|
||||
setScrapeEpisodeArtwork,
|
||||
handleScrape,
|
||||
handleRepairRescrape,
|
||||
handleSeriesSmartScrape,
|
||||
handleSeriesProbe,
|
||||
@@ -125,7 +125,7 @@ export function LibraryPage() {
|
||||
repairing={repairing}
|
||||
onScrapeEpisodeArtworkChange={setScrapeEpisodeArtwork}
|
||||
onScan={handleScan}
|
||||
onScrape={handleScrape}
|
||||
onScrape={() => setScrapeDialogOpen(true)}
|
||||
onRepairRescrape={handleRepairRescrape}
|
||||
/>
|
||||
|
||||
@@ -161,6 +161,8 @@ export function LibraryPage() {
|
||||
/>
|
||||
|
||||
<LibraryPageDialogs
|
||||
scrapeDialogOpen={scrapeDialogOpen}
|
||||
library={library}
|
||||
manualSeriesScrapeOpen={manualSeriesScrapeOpen}
|
||||
seriesMetadataEditOpen={seriesMetadataEditOpen}
|
||||
manualMovie={manualMovie}
|
||||
@@ -168,6 +170,8 @@ export function LibraryPage() {
|
||||
selectedSeriesMediaIDs={selectedSeriesMediaIDs}
|
||||
libraryType={library?.type}
|
||||
scrapeEpisodeArtwork={scrapeEpisodeArtwork}
|
||||
onScrapeEpisodeArtworkChange={setScrapeEpisodeArtwork}
|
||||
onCloseScrapeDialog={() => setScrapeDialogOpen(false)}
|
||||
onCloseManualSeriesScrape={() => setManualSeriesScrapeOpen(false)}
|
||||
onCloseSeriesMetadataEdit={() => setSeriesMetadataEditOpen(false)}
|
||||
onCloseManualMovie={() => setManualMovie(null)}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { ManualScrapeDialog } from '../components/ManualScrapeDialog'
|
||||
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'
|
||||
|
||||
type LibraryPageDialogsProps = {
|
||||
scrapeDialogOpen: boolean
|
||||
library: Library | null
|
||||
manualSeriesScrapeOpen: boolean
|
||||
seriesMetadataEditOpen: boolean
|
||||
manualMovie: Media | null
|
||||
@@ -11,6 +14,8 @@ type LibraryPageDialogsProps = {
|
||||
selectedSeriesMediaIDs: string[]
|
||||
libraryType?: string
|
||||
scrapeEpisodeArtwork: boolean
|
||||
onScrapeEpisodeArtworkChange: (checked: boolean) => void
|
||||
onCloseScrapeDialog: () => void
|
||||
onCloseManualSeriesScrape: () => void
|
||||
onCloseSeriesMetadataEdit: () => void
|
||||
onCloseManualMovie: () => void
|
||||
@@ -18,6 +23,8 @@ type LibraryPageDialogsProps = {
|
||||
}
|
||||
|
||||
export function LibraryPageDialogs({
|
||||
scrapeDialogOpen,
|
||||
library,
|
||||
manualSeriesScrapeOpen,
|
||||
seriesMetadataEditOpen,
|
||||
manualMovie,
|
||||
@@ -25,6 +32,8 @@ export function LibraryPageDialogs({
|
||||
selectedSeriesMediaIDs,
|
||||
libraryType,
|
||||
scrapeEpisodeArtwork,
|
||||
onScrapeEpisodeArtworkChange,
|
||||
onCloseScrapeDialog,
|
||||
onCloseManualSeriesScrape,
|
||||
onCloseSeriesMetadataEdit,
|
||||
onCloseManualMovie,
|
||||
@@ -34,6 +43,14 @@ export function LibraryPageDialogs({
|
||||
|
||||
return (
|
||||
<>
|
||||
<ScrapeMetadataDialog
|
||||
open={scrapeDialogOpen}
|
||||
library={library}
|
||||
scrapeEpisodeArtwork={scrapeEpisodeArtwork}
|
||||
onScrapeEpisodeArtworkChange={onScrapeEpisodeArtworkChange}
|
||||
onClose={onCloseScrapeDialog}
|
||||
onCompleted={onApplied}
|
||||
/>
|
||||
<ManualScrapeDialog
|
||||
open={manualSeriesScrapeOpen}
|
||||
media={selectedSeries?.rep ?? null}
|
||||
|
||||
Reference in New Issue
Block a user