From b40e2c7280025560b3b371eaca19e3a8ce8301c0 Mon Sep 17 00:00:00 2001 From: ShukeBta <272197458+ShukeBta@users.noreply.github.com> Date: Fri, 19 Jun 2026 21:26:51 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E8=AE=A2=E9=98=85=E6=81=A2=E5=A4=8D?= =?UTF-8?q?=E8=A1=A5=E7=BC=BA=E9=9B=86/=E5=8D=95=E5=AA=92=E4=BD=93?= =?UTF-8?q?=E6=89=8B=E5=8A=A8=E6=95=B4=E7=90=86=E5=85=A5=E5=8F=A3/?= =?UTF-8?q?=E5=AA=92=E4=BD=93=E5=BA=93=E6=8E=92=E5=BA=8F/=E7=BD=91?= =?UTF-8?q?=E7=9B=98=E5=AA=92=E4=BD=93=E5=88=86=E7=B1=BB=E8=BF=81=E7=A7=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 订阅(问题1): selectPreparedSubscriptionCandidates 在本地已有部分集时不再 无条件丢弃整季/全集包,单集候选不足时用全集包兜底;Restore 重置 total_episodes 让下次 run 从权威元数据重算,避免被 feed 低估值锁死误判'无缺集' - 单媒体整理(问题2): 后端已具备,新增 OrganizeMediaDialog 组件并在媒体详情页 接入'整理入库'按钮(可选类型/二级分类/目标路径/转移方式/预览) - 媒体库排序(问题3): ListByLibrariesFiltered 改多级排序 year/updated_at/ created_at/id,id 作稳定 tie-breaker 消除云盘批量扫描同批时间戳的随机观感 - 网盘媒体消失(问题4): 云盘媒体 upsert 时允许 library_id 迁移到重新挂载的 更精确分类库(本地媒体物理位置固定不迁移),修复一键挂载子目录后媒体消失 --- internal/repository/repository.go | 19 +- internal/repository/repository_test.go | 65 +++++++ internal/service/subscription.go | 5 + internal/service/subscription_planner.go | 18 +- internal/service/subscription_test.go | 14 +- web/src/api/tools.ts | 1 + web/src/components/OrganizeMediaDialog.tsx | 201 +++++++++++++++++++++ web/src/pages/MediaDetailPage.tsx | 14 +- 8 files changed, 328 insertions(+), 9 deletions(-) create mode 100644 web/src/components/OrganizeMediaDialog.tsx diff --git a/internal/repository/repository.go b/internal/repository/repository.go index 3fbaa48..ef6c93f 100644 --- a/internal/repository/repository.go +++ b/internal/repository/repository.go @@ -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 } diff --git a/internal/repository/repository_test.go b/internal/repository/repository_test.go index 7d67e46..4c2625e 100644 --- a/internal/repository/repository_test.go +++ b/internal/repository/repository_test.go @@ -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 diff --git a/internal/service/subscription.go b/internal/service/subscription.go index 6abbe6f..ca69843 100644 --- a/internal/service/subscription.go +++ b/internal/service/subscription.go @@ -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 } diff --git a/internal/service/subscription_planner.go b/internal/service/subscription_planner.go index 5e78bcd..d3308b7 100644 --- a/internal/service/subscription_planner.go +++ b/internal/service/subscription_planner.go @@ -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 { diff --git a/internal/service/subscription_test.go b/internal/service/subscription_test.go index 9b933ab..7bf4bb0 100644 --- a/internal/service/subscription_test.go +++ b/internal/service/subscription_test.go @@ -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) diff --git a/web/src/api/tools.ts b/web/src/api/tools.ts index 9a793b8..383062c 100644 --- a/web/src/api/tools.ts +++ b/web/src/api/tools.ts @@ -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 diff --git a/web/src/components/OrganizeMediaDialog.tsx b/web/src/components/OrganizeMediaDialog.tsx new file mode 100644 index 0000000..3f6cc7c --- /dev/null +++ b/web/src/components/OrganizeMediaDialog.tsx @@ -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 +} + +// 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 ( +
+
+
+
+

整理入库

+

+ 把「{media.title}」按命名规范转移到媒体目录。留空则自动识别类型与二级分类。 +

+
+ +
+ +
+ {isCloud && ( +
+ 该媒体位于云盘库,本地整理无法直接移动云盘文件。请在外部存储中启用云盘转移,或对本地媒体使用本功能。 +
+ )} + + set('media_category', v)} + placeholder="如 华语电影 / 国产剧 / 日番" + /> + set('dest_path', v)} + placeholder="如 /media" + /> + + + + + {preview && ( +
+ 预览目标路径: + {preview} +
+ )} +
+ +
+ + + +
+
+
+ ) +} + +function Field({ + label, + value, + onChange, + placeholder, +}: { + label: string + value: string + onChange: (value: string) => void + placeholder?: string +}) { + return ( + + ) +} diff --git a/web/src/pages/MediaDetailPage.tsx b/web/src/pages/MediaDetailPage.tsx index 7f5020e..3781d0c 100644 --- a/web/src/pages/MediaDetailPage.tsx +++ b/web/src/pages/MediaDetailPage.tsx @@ -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() { 编辑元数据 +