diff --git a/.gitignore b/.gitignore index 5c8c730..d56b20c 100644 --- a/.gitignore +++ b/.gitignore @@ -79,3 +79,4 @@ media/ tools/ verify-cache/ verify-data/ +.zcode/ \ No newline at end of file diff --git a/internal/handler/scrape_reclassify.go b/internal/handler/scrape_reclassify.go index abfdc2c..ac51131 100644 --- a/internal/handler/scrape_reclassify.go +++ b/internal/handler/scrape_reclassify.go @@ -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 } diff --git a/internal/service/cloud_path_rescrape.go b/internal/service/cloud_path_rescrape.go index 0aacb0a..e51ca76 100644 --- a/internal/service/cloud_path_rescrape.go +++ b/internal/service/cloud_path_rescrape.go @@ -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 { diff --git a/internal/service/scanner_post_scan.go b/internal/service/scanner_post_scan.go index ffa1b7b..9719734 100644 --- a/internal/service/scanner_post_scan.go +++ b/internal/service/scanner_post_scan.go @@ -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)) - } - } }() } diff --git a/web/src/api/tools.ts b/web/src/api/tools.ts index 085f7a4..a2a3c37 100644 --- a/web/src/api/tools.ts +++ b/web/src/api/tools.ts @@ -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>( + .post( `/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( '/admin/organize/source', opts, ) diff --git a/web/src/components/ScrapeMetadataDialog.tsx b/web/src/components/ScrapeMetadataDialog.tsx new file mode 100644 index 0000000..207a037 --- /dev/null +++ b/web/src/components/ScrapeMetadataDialog.tsx @@ -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 +} + +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('current') + const [actionType, setActionType] = useState('organize_and_scrape') + const [destPath, setDestPath] = useState('') + const [transferMode, setTransferMode] = useState('hardlink') + const [refreshMatched, setRefreshMatched] = useState(true) + + const [availableLibraries, setAvailableLibraries] = useState([]) + const [busy, setBusy] = useState(false) + const [previewBusy, setPreviewBusy] = useState(false) + const [previewResult, setPreviewResult] = useState(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 ( +
+
+ {/* Header */} +
+
+
+ +
+
+

刮削元数据与整理

+

+ 对媒体库「{library.name}」执行刮削与文件整理 +

+
+
+ +
+ + {/* Content Body */} +
+ {isCloud && ( +
+ + 当前为云盘媒体库,文件整理将受限。推荐仅使用元数据刮削,或在外部存储中配置云盘挂载/转移。 +
+ )} + + {/* Mode Selection Cards */} +
+ +
+ {/* Option 1: Current Folder */} +
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' + }`} + > +
+
+ + 媒体当前文件夹 +
+ {targetMode === 'current' && } +
+

+ 直接在当前媒体库目录内进行刮削整理,无需迁移文件。 +

+
+ {library.path} +
+
+ + {/* Option 2: Custom Specified Folder */} +
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' + }`} + > +
+
+ + 整理到指定文件夹 +
+ {targetMode === 'custom' && } +
+

+ 将媒体规范整理转移至指定目标目录,并自动扫描入库刮削。 +

+
+ {destPath.trim() || '需自定义目标路径…'} +
+
+
+
+ + {/* Sub-options for Current Folder Mode */} + {targetMode === 'current' && ( +
+ +
+ + +
+
+ )} + + {/* Custom Destination Input (when targetMode === 'custom') */} + {targetMode === 'custom' && ( +
+
+ +
+ 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" + /> +
+
+ + {availableLibraries.length > 0 && ( +
+ 快捷填入已有媒体库路径: +
+ {availableLibraries.map((lib) => ( + + ))} +
+
+ )} +
+ )} + + {/* Transfer Mode & Advanced Scrape Options */} +
+ {isOrganize && ( + + )} + +
+ 刮削偏好设置 +
+ + +
+
+
+ + {/* Preview Results */} + {previewResult && ( +
+
+ + 整理预览结果: +
+
+ 待新增:{previewResult.organized ?? 0} + 替换/洗版:{previewResult.replaced ?? 0} + 纠偏:{previewResult.reclassified ?? 0} + 已在目标/跳过:{previewResult.skipped ?? 0} +
+ {previewResult.errors && previewResult.errors.length > 0 && ( +
+ 部分异常 ({previewResult.errors.length}): +
    + {previewResult.errors.slice(0, 3).map((err, i) => ( +
  • {err}
  • + ))} +
+
+ )} +
+ )} +
+ + {/* Footer Actions */} +
+
+ {isOrganize && ( + + )} +
+
+ + +
+
+
+
+ ) +} diff --git a/web/src/pages/LibraryPage.tsx b/web/src/pages/LibraryPage.tsx index 4d631c3..917e5e9 100644 --- a/web/src/pages/LibraryPage.tsx +++ b/web/src/pages/LibraryPage.tsx @@ -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(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() { /> setScrapeDialogOpen(false)} onCloseManualSeriesScrape={() => setManualSeriesScrapeOpen(false)} onCloseSeriesMetadataEdit={() => setSeriesMetadataEditOpen(false)} onCloseManualMovie={() => setManualMovie(null)} diff --git a/web/src/pages/LibraryPageDialogs.tsx b/web/src/pages/LibraryPageDialogs.tsx index 7fef288..2122649 100644 --- a/web/src/pages/LibraryPageDialogs.tsx +++ b/web/src/pages/LibraryPageDialogs.tsx @@ -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 ( <> +