mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 11:36:36 +08:00
fix: 订阅恢复补缺集/单媒体手动整理入口/媒体库排序/网盘媒体分类迁移
- 订阅(问题1): selectPreparedSubscriptionCandidates 在本地已有部分集时不再 无条件丢弃整季/全集包,单集候选不足时用全集包兜底;Restore 重置 total_episodes 让下次 run 从权威元数据重算,避免被 feed 低估值锁死误判'无缺集' - 单媒体整理(问题2): 后端已具备,新增 OrganizeMediaDialog 组件并在媒体详情页 接入'整理入库'按钮(可选类型/二级分类/目标路径/转移方式/预览) - 媒体库排序(问题3): ListByLibrariesFiltered 改多级排序 year/updated_at/ created_at/id,id 作稳定 tie-breaker 消除云盘批量扫描同批时间戳的随机观感 - 网盘媒体消失(问题4): 云盘媒体 upsert 时允许 library_id 迁移到重新挂载的 更精确分类库(本地媒体物理位置固定不迁移),修复一键挂载子目录后媒体消失
This commit is contained in:
@@ -456,8 +456,14 @@ func (r *MediaRepository) upsert(ctx context.Context, m *model.Media) error {
|
||||
if m.BackdropURL != "" {
|
||||
setIfChanged(updates, "backdrop_url", existing.BackdropURL, m.BackdropURL)
|
||||
}
|
||||
if lib := m.LibraryID; lib != "" && lib != existing.LibraryID {
|
||||
updates["library_id"] = m.LibraryID
|
||||
// 云盘媒体:同一 cloud:// 文件可能先被父目录库扫描入库,之后用户按二级
|
||||
// 分类重新挂载/扫描到更精确的分类库。此时让 library_id 迁移到当前扫描库,
|
||||
// 否则媒体被钉死在旧库、新分类库里看不到(表现为"媒体部分消失")。
|
||||
// 本地媒体物理位置固定:仅在原 library_id 为空时回填,不迁移。
|
||||
if isCloudMediaPath := strings.HasPrefix(strings.ToLower(strings.TrimSpace(m.Path)), "cloud://"); m.LibraryID != "" && m.LibraryID != existing.LibraryID {
|
||||
if isCloudMediaPath || existing.LibraryID == "" {
|
||||
updates["library_id"] = m.LibraryID
|
||||
}
|
||||
}
|
||||
if (m.SeasonNum > 0 || m.EpisodeNum > 0) && existing.SeasonNum != m.SeasonNum {
|
||||
updates["season_num"] = m.SeasonNum
|
||||
@@ -537,7 +543,14 @@ func (r *MediaRepository) ListByLibrariesFiltered(ctx context.Context, libraryID
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
err := q.Order("created_at desc").Offset(offset).Limit(limit).Find(&items).Error
|
||||
// 多级排序消除"随机"观感:
|
||||
// 1. year desc — 上映年份新→旧(用户期望的上映时间维度)
|
||||
// 2. updated_at desc — 同年按最近更新(刮削/补集会刷新)
|
||||
// 3. created_at desc — 再按入库时间
|
||||
// 4. id desc — 稳定 tie-breaker:云盘批量扫描同批 created_at 相同时,
|
||||
// 没有它 DB 返回顺序不确定,正是"随机排序"的根因。
|
||||
err := q.Order("year DESC, updated_at DESC, created_at DESC, id DESC").
|
||||
Offset(offset).Limit(limit).Find(&items).Error
|
||||
return items, total, err
|
||||
}
|
||||
|
||||
|
||||
@@ -118,6 +118,71 @@ func TestMediaUpsertRefreshesCloudExternalIDFromPathHint(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestMediaUpsertMigratesCloudLibraryIDOnRescan 复现"一键挂载子目录后媒体消失"的
|
||||
// 回归:同一 cloud:// 文件先被父目录库扫描入库,之后用户按二级分类重新挂载到更
|
||||
// 精确的分类库并扫描,library_id 必须迁移到新分类库,否则媒体被钉死在旧库、新库
|
||||
// 视图里看不到。本地媒体物理位置固定,不参与迁移。
|
||||
func TestMediaUpsertMigratesCloudLibraryIDOnRescan(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.AutoMigrate(db); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := New(db)
|
||||
parent := model.Library{Name: "OpenList", Path: "cloud://openlist", Type: "movie", Enabled: true}
|
||||
if err := repos.Library.Create(t.Context(), &parent); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
category := model.Library{Name: "国漫", Path: "cloud://openlist/国漫", Type: "anime", Enabled: true}
|
||||
if err := repos.Library.Create(t.Context(), &category); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := "cloud://openlist/国漫/成何体统 (2024) {tmdb-256783}/Season 1/成何体统.S01E01.mkv"
|
||||
// 第一次:被父目录库扫描入库。
|
||||
first := model.Media{LibraryID: parent.ID, Title: "成何体统", Path: path, SeasonNum: 1, EpisodeNum: 1}
|
||||
if err := repos.Media.Upsert(t.Context(), &first); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 第二次:按二级分类重新挂载并扫描,归入更精确的「国漫」库。
|
||||
second := model.Media{LibraryID: category.ID, Title: "成何体统", Path: path, SeasonNum: 1, EpisodeNum: 1}
|
||||
if err := repos.Media.Upsert(t.Context(), &second); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got model.Media
|
||||
if err := repos.DB.Where("path = ?", path).First(&got).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.LibraryID != category.ID {
|
||||
t.Fatalf("cloud media library_id should migrate to category library %q, got %q", category.ID, got.LibraryID)
|
||||
}
|
||||
|
||||
// 本地媒体不迁移:同一物理路径不应改库归属。
|
||||
localA := model.Library{Name: "Movies A", Path: "/media/a", Type: "movie", Enabled: true}
|
||||
localB := model.Library{Name: "Movies B", Path: "/media/b", Type: "movie", Enabled: true}
|
||||
if err := repos.Library.Create(t.Context(), &localA); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repos.Library.Create(t.Context(), &localB); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
localPath := "/media/a/Inception (2010)/Inception.mkv"
|
||||
if err := repos.Media.Upsert(t.Context(), &model.Media{LibraryID: localA.ID, Title: "Inception", Path: localPath}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repos.Media.Upsert(t.Context(), &model.Media{LibraryID: localB.ID, Title: "Inception", Path: localPath}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var localGot model.Media
|
||||
if err := repos.DB.Where("path = ?", localPath).First(&localGot).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if localGot.LibraryID != localA.ID {
|
||||
t.Fatalf("local media library_id must not migrate, want %q got %q", localA.ID, localGot.LibraryID)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeMediaSearchBackend struct {
|
||||
ids []string
|
||||
err error
|
||||
|
||||
@@ -143,6 +143,11 @@ func (s *SubscriptionService) Restore(ctx context.Context, id string) (*model.Su
|
||||
Updates(map[string]any{
|
||||
"enabled": true,
|
||||
"archive_reason": "",
|
||||
// 重置为 0:此前可能被 feed 低估并锁死(updateSubscriptionTotalEpisodes
|
||||
// 只增不减,resolveSubscriptionTotalEpisodes 见 >0 即不再回查元数据)。
|
||||
// 归零后下次 run 会从 TMDb/豆瓣等权威源重算真实总集数,避免恢复后
|
||||
// 因"误判已无缺集"而不再搜索资源。
|
||||
"total_episodes": 0,
|
||||
}).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -157,8 +157,16 @@ func selectPreparedSubscriptionCandidates(candidates []siteSearchCandidate, sub
|
||||
}
|
||||
missingSet := missingEpisodeSet(local)
|
||||
onlyMissing := make([]siteSearchCandidate, 0, len(candidates))
|
||||
for _, candidate := range candidates {
|
||||
var packFallback *siteSearchCandidate
|
||||
for i := range candidates {
|
||||
candidate := candidates[i]
|
||||
if candidate.Episode <= 0 {
|
||||
// 整季/全集包(无单集号)。剧集完结后站点常只挂全集包,
|
||||
// 这里记下来作兜底:当单集候选不足以补齐缺失集时启用,
|
||||
// 否则"补全缺失集"在站点只有全集包时永远匹配为空。
|
||||
if candidate.Pack && packFallback == nil {
|
||||
packFallback = &candidates[i]
|
||||
}
|
||||
continue
|
||||
}
|
||||
season := candidate.Season
|
||||
@@ -175,7 +183,13 @@ func selectPreparedSubscriptionCandidates(candidates []siteSearchCandidate, sub
|
||||
}
|
||||
onlyMissing = append(onlyMissing, candidate)
|
||||
}
|
||||
return sortedEpisodeCandidates(onlyMissing)
|
||||
selected := sortedEpisodeCandidates(onlyMissing)
|
||||
if len(selected) == 0 && packFallback != nil {
|
||||
// 没有可用的单集候选,但站点有整季/全集包 → 用包兜底补缺集。
|
||||
// 代价是会重下已有集,但用户主动触发补全时这是可接受的。
|
||||
return []siteSearchCandidate{*packFallback}
|
||||
}
|
||||
return selected
|
||||
}
|
||||
|
||||
for _, candidate := range candidates {
|
||||
|
||||
@@ -283,7 +283,9 @@ func TestSelectSiteSearchCandidatesSingleExistingEpisodeIsSkipped(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectSiteSearchCandidatesSinglePackIsSkippedWhenLibraryPartiallyExists(t *testing.T) {
|
||||
func TestSelectSiteSearchCandidatesFullPackUsedAsFallbackWhenLibraryPartiallyExists(t *testing.T) {
|
||||
// 本地缺第 3 集,站点只有整季全集包(无单集种)。剧集完结后站点常只挂全集包,
|
||||
// 此时必须用全集包兜底补缺集,否则"补全缺失集"永远匹配为空(用户报告的 bug)。
|
||||
sub := &model.Subscription{Name: "间谍过家家 自动订阅", Filter: "间谍过家家", MediaType: "tv", TotalEpisodes: 3}
|
||||
results := []SearchResult{
|
||||
{Title: "间谍过家家 S01 Complete 1080p", DownloadURL: "https://pt/download/pack", Seeders: 100},
|
||||
@@ -296,8 +298,11 @@ func TestSelectSiteSearchCandidatesSinglePackIsSkippedWhenLibraryPartiallyExists
|
||||
}
|
||||
|
||||
got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}, availability)
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("selected %#v, want none because a full pack would redownload existing episodes", got)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("selected %#v, want the full pack as fallback to cover missing episode 3", got)
|
||||
}
|
||||
if got[0].Download != "https://pt/download/pack" {
|
||||
t.Fatalf("selected %#v, want the Complete pack", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -845,6 +850,9 @@ func TestRestoreArchivedSubscriptionReturnsToActiveAndClearsSeenState(t *testing
|
||||
if restored.ArchivedAt != nil || restored.ArchiveReason != "" || !restored.Enabled {
|
||||
t.Fatalf("restored subscription not active: archived=%v reason=%q enabled=%v", restored.ArchivedAt, restored.ArchiveReason, restored.Enabled)
|
||||
}
|
||||
if restored.TotalEpisodes != 0 {
|
||||
t.Fatalf("restored total_episodes = %d, want 0 so it gets recomputed from authoritative metadata", restored.TotalEpisodes)
|
||||
}
|
||||
active, err := repos.Subscription.List(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface OrganizeOverrides {
|
||||
dest_path?: string
|
||||
transfer_mode?: string
|
||||
media_type?: string
|
||||
media_category?: string
|
||||
scan_after?: boolean
|
||||
scrape_after?: boolean
|
||||
library_id?: string
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { FolderInput, X } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { toolsAPI, type OrganizeOverrides } from '../api/tools'
|
||||
import type { Media } from '../types'
|
||||
|
||||
interface OrganizeMediaDialogProps {
|
||||
open: boolean
|
||||
media: Media | null
|
||||
onClose: () => void
|
||||
onOrganized?: () => void | Promise<void>
|
||||
}
|
||||
|
||||
// OrganizeMediaDialog 对单个媒体执行「手动整理入库」:把该媒体按命名规范转移到
|
||||
// 目标媒体目录,并可手动覆盖类型/二级分类/目标路径/转移方式。后端 handler 为
|
||||
// POST /admin/media/:id/organize(见 organizeMediaHandler)。云盘媒体不支持本地整理。
|
||||
export function OrganizeMediaDialog({ open, media, onClose, onOrganized }: OrganizeMediaDialogProps) {
|
||||
const [form, setForm] = useState({
|
||||
media_type: '',
|
||||
media_category: '',
|
||||
dest_path: '',
|
||||
transfer_mode: 'hardlink',
|
||||
scan_after: true,
|
||||
scrape_after: true,
|
||||
dry_run: false,
|
||||
})
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [preview, setPreview] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setPreview('')
|
||||
setForm((prev) => ({ ...prev, media_type: '', media_category: '', dest_path: '', dry_run: false }))
|
||||
}, [open, media])
|
||||
|
||||
if (!open || !media) return null
|
||||
|
||||
const isCloud = (media.path || '').toLowerCase().startsWith('cloud://')
|
||||
|
||||
const set = (key: keyof typeof form, value: string | boolean) => {
|
||||
setForm((prev) => ({ ...prev, [key]: value }))
|
||||
}
|
||||
|
||||
const run = async (dryRun: boolean) => {
|
||||
if (busy) return
|
||||
setBusy(true)
|
||||
setPreview('')
|
||||
try {
|
||||
const opts: OrganizeOverrides = {
|
||||
transfer_mode: form.transfer_mode,
|
||||
scan_after: form.scan_after,
|
||||
scrape_after: form.scrape_after,
|
||||
dry_run: dryRun,
|
||||
}
|
||||
if (form.media_type.trim()) opts.media_type = form.media_type.trim()
|
||||
if (form.media_category.trim()) opts.media_category = form.media_category.trim()
|
||||
if (form.dest_path.trim()) opts.dest_path = form.dest_path.trim()
|
||||
const r = await toolsAPI.organizeMedia(media.id, opts)
|
||||
if (dryRun) {
|
||||
setPreview(r.path || '(未返回目标路径)')
|
||||
toast.success('已生成整理预览')
|
||||
} else {
|
||||
toast.success('整理完成')
|
||||
await onOrganized?.()
|
||||
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-8 backdrop-blur-sm">
|
||||
<div className="flex max-h-[88vh] w-full max-w-2xl flex-col overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-2xl">
|
||||
<div className="flex items-start justify-between gap-4 border-b border-gray-200 px-5 py-4">
|
||||
<div>
|
||||
<h2 className="font-display text-xl font-bold text-gray-900">整理入库</h2>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
把「{media.title}」按命名规范转移到媒体目录。留空则自动识别类型与二级分类。
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="btn-ghost h-9 w-9 p-0" aria-label="关闭">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid flex-1 gap-4 overflow-y-auto p-5 md:grid-cols-2">
|
||||
{isCloud && (
|
||||
<div className="md:col-span-2 rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-xs font-semibold text-amber-700">
|
||||
该媒体位于云盘库,本地整理无法直接移动云盘文件。请在外部存储中启用云盘转移,或对本地媒体使用本功能。
|
||||
</div>
|
||||
)}
|
||||
<label>
|
||||
<span className="mb-1 block text-xs font-bold text-gray-500">类型(留空自动识别)</span>
|
||||
<select
|
||||
value={form.media_type}
|
||||
onChange={(e) => set('media_type', e.target.value)}
|
||||
className="h-11 w-full rounded-xl border border-gray-200 bg-white px-3 text-sm font-semibold text-gray-700 outline-none focus:border-brand-300"
|
||||
>
|
||||
<option value="">自动识别</option>
|
||||
<option value="movie">电影</option>
|
||||
<option value="tv">电视剧</option>
|
||||
<option value="anime">动漫</option>
|
||||
<option value="variety">综艺</option>
|
||||
<option value="adult">成人</option>
|
||||
</select>
|
||||
</label>
|
||||
<Field
|
||||
label="二级分类(留空自动识别)"
|
||||
value={form.media_category}
|
||||
onChange={(v) => set('media_category', v)}
|
||||
placeholder="如 华语电影 / 国产剧 / 日番"
|
||||
/>
|
||||
<Field
|
||||
label="目标媒体目录(留空用默认)"
|
||||
value={form.dest_path}
|
||||
onChange={(v) => set('dest_path', v)}
|
||||
placeholder="如 /media"
|
||||
/>
|
||||
<label>
|
||||
<span className="mb-1 block text-xs font-bold text-gray-500">转移方式</span>
|
||||
<select
|
||||
value={form.transfer_mode}
|
||||
onChange={(e) => set('transfer_mode', e.target.value)}
|
||||
className="h-11 w-full rounded-xl border border-gray-200 bg-white px-3 text-sm font-semibold text-gray-700 outline-none focus:border-brand-300"
|
||||
>
|
||||
<option value="hardlink">硬链接</option>
|
||||
<option value="symlink">软链接</option>
|
||||
<option value="copy">复制</option>
|
||||
<option value="move">移动</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex h-11 items-center gap-2 rounded-xl border border-gray-200 px-3 text-sm font-semibold text-gray-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.scrape_after}
|
||||
onChange={(e) => set('scrape_after', e.target.checked)}
|
||||
className="h-4 w-4 rounded border-gray-300 text-brand-600"
|
||||
/>
|
||||
整理后刮削
|
||||
</label>
|
||||
<label className="flex h-11 items-center gap-2 rounded-xl border border-gray-200 px-3 text-sm font-semibold text-gray-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.scan_after}
|
||||
onChange={(e) => set('scan_after', e.target.checked)}
|
||||
className="h-4 w-4 rounded border-gray-300 text-brand-600"
|
||||
/>
|
||||
整理后扫描入库
|
||||
</label>
|
||||
|
||||
{preview && (
|
||||
<div className="md:col-span-2 rounded-xl border border-gray-200 bg-gray-50 px-3 py-2 text-xs text-gray-700">
|
||||
<span className="font-bold text-gray-500">预览目标路径:</span>
|
||||
<span className="break-all">{preview}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 border-t border-gray-200 px-5 py-4">
|
||||
<button onClick={onClose} className="btn-outline px-4">取消</button>
|
||||
<button onClick={() => run(true)} disabled={busy || isCloud} className="btn-outline px-4">
|
||||
预览路径
|
||||
</button>
|
||||
<button onClick={() => run(false)} disabled={busy || isCloud} className="btn-primary px-5">
|
||||
<FolderInput size={16} />
|
||||
{busy ? '整理中…' : '整理入库'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string
|
||||
}) {
|
||||
return (
|
||||
<label>
|
||||
<span className="mb-1 block text-xs font-bold text-gray-500">{label}</span>
|
||||
<input
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="h-11 w-full rounded-xl border border-gray-200 bg-white px-3 text-sm font-semibold text-gray-700 outline-none focus:border-brand-300"
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { motion } from 'framer-motion'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import { FileText, Heart, Play, RefreshCw, Sparkles, Trash2, Calendar, Database, Search, Pencil } from 'lucide-react'
|
||||
import { FileText, Heart, Play, RefreshCw, Sparkles, Trash2, Calendar, Database, Search, Pencil, FolderInput } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { mediaAPI } from '../api/library'
|
||||
@@ -15,6 +15,7 @@ import { confirmAction } from '../components/ConfirmDialog'
|
||||
import { ExternalPlayerButton } from '../components/ExternalPlayerButton'
|
||||
import { ManualScrapeDialog } from '../components/ManualScrapeDialog'
|
||||
import { MetadataEditDialog } from '../components/MetadataEditDialog'
|
||||
import { OrganizeMediaDialog } from '../components/OrganizeMediaDialog'
|
||||
|
||||
function fmtDuration(sec: number): string {
|
||||
if (!sec || sec <= 0) return '—'
|
||||
@@ -49,6 +50,7 @@ export function MediaDetailPage() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [manualScrapeOpen, setManualScrapeOpen] = useState(false)
|
||||
const [metadataEditOpen, setMetadataEditOpen] = useState(false)
|
||||
const [organizeOpen, setOrganizeOpen] = useState(false)
|
||||
|
||||
const refresh = async () => {
|
||||
if (!id) return
|
||||
@@ -327,6 +329,10 @@ export function MediaDetailPage() {
|
||||
<Pencil size={13} className="text-gray-600" />
|
||||
<span>编辑元数据</span>
|
||||
</button>
|
||||
<button onClick={() => setOrganizeOpen(true)} className="btn-outline py-2 px-3.5 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50">
|
||||
<FolderInput size={13} className="text-[#c9954a]" />
|
||||
<span>整理入库</span>
|
||||
</button>
|
||||
<button onClick={reprobe} className="btn-outline py-2 px-3.5 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50">
|
||||
<Database size={13} className="text-gray-600" />
|
||||
<span>探测媒体轨 (ffprobe)</span>
|
||||
@@ -366,6 +372,12 @@ export function MediaDetailPage() {
|
||||
await refresh()
|
||||
}}
|
||||
/>
|
||||
<OrganizeMediaDialog
|
||||
open={organizeOpen}
|
||||
media={media}
|
||||
onClose={() => setOrganizeOpen(false)}
|
||||
onOrganized={refresh}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user