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:
ShukeBta
2026-06-19 21:26:51 +08:00
parent 9b4884359b
commit b40e2c7280
8 changed files with 328 additions and 9 deletions
+16 -3
View File
@@ -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
}
+65
View File
@@ -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
+5
View File
@@ -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
}
+16 -2
View File
@@ -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 {
+11 -3
View File
@@ -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)