diff --git a/internal/service/media_listing.go b/internal/service/media_listing.go index edb0e6b..55a9975 100644 --- a/internal/service/media_listing.go +++ b/internal/service/media_listing.go @@ -2,7 +2,6 @@ package service import ( "context" - "sort" "strings" "time" @@ -179,9 +178,18 @@ func (s *MediaService) listVersionSiblings(ctx context.Context, media *model.Med libraryIDs = []string{media.LibraryID} } filter := repository.MediaQueryFilter{IncludeNSFW: true} - candidates, narrowed, err := s.repo.Media.ListVersionCandidates(ctx, libraryIDs, *media, 5000) - if err != nil { - return nil, err + // 成人条目按番号分组,而番号常常只存在于路径/标题里:本地 NFO 来源的分片 + // 没有 douban_id/thetvdb_id(MetaTube 刮削出来的那几个才有)。若仍按外部 + // ID 预先收窄候选集,同番号的其它分片会被 SQL 直接排除,表现就是库里折叠 + // 出了 N 个版本、详情页却只列出带 ID 的那几个。因此成人条目不预先收窄, + // 直接在该库范围内比对版本键。 + var candidates []model.Media + narrowed := false + if mediaAdultGroupCode(*media) == "" { + candidates, narrowed, err = s.repo.Media.ListVersionCandidates(ctx, libraryIDs, *media, 5000) + if err != nil { + return nil, err + } } if !narrowed { candidates, err = s.repo.Media.ListByLibrariesFilteredNoCount(ctx, libraryIDs, 0, 5000, filter) @@ -199,14 +207,6 @@ func (s *MediaService) listVersionSiblings(ctx context.Context, media *model.Med if len(matched) == 0 { return []model.Media{*media}, nil } - sort.SliceStable(matched, func(i, j int) bool { - if matched[i].ID == media.ID { - return true - } - if matched[j].ID == media.ID { - return false - } - return betterMediaVersion(matched[i], matched[j]) - }) + sortMediaVersionsForDisplay(matched) return matched, nil } diff --git a/internal/service/media_versions.go b/internal/service/media_versions.go index 5eb038b..8d5d590 100644 --- a/internal/service/media_versions.go +++ b/internal/service/media_versions.go @@ -3,6 +3,7 @@ package service import ( "fmt" "path/filepath" + "regexp" "sort" "strconv" "strings" @@ -77,34 +78,28 @@ func groupMediaVersions(items []model.Media) []MediaItem { return nil } type group struct { - key string - primary model.Media - rows []model.Media + key string + rows []model.Media } groups := make([]group, 0, len(items)) byKey := make(map[string]int, len(items)) for _, item := range items { key := mediaVersionGroupKey(item) if key == "" { - groups = append(groups, group{primary: item, rows: []model.Media{item}}) + groups = append(groups, group{rows: []model.Media{item}}) continue } if idx, ok := byKey[key]; ok { groups[idx].rows = append(groups[idx].rows, item) - if betterMediaVersion(item, groups[idx].primary) { - groups[idx].primary = item - } continue } byKey[key] = len(groups) - groups = append(groups, group{key: key, primary: item, rows: []model.Media{item}}) + groups = append(groups, group{key: key, rows: []model.Media{item}}) } out := make([]MediaItem, 0, len(groups)) for _, g := range groups { - sort.SliceStable(g.rows, func(i, j int) bool { - return betterMediaVersion(g.rows[i], g.rows[j]) - }) - item := MediaItem{Media: g.primary} + sortMediaVersionsForDisplay(g.rows) + item := MediaItem{Media: g.rows[0]} if len(g.rows) > 1 { item.Versions = g.rows } @@ -351,8 +346,11 @@ func mediaVersionFileStem(name string) string { } // MediaVersionLabel 生成版本切换展示名(分辨率 / 容器 / 编码 / 体积 / 文件名)。 +// 当只有容器等无法区分多版本的信息时,回退到清理后的文件名。 func MediaVersionLabel(m model.Media) string { parts := make([]string, 0, 4) + isStrm := strings.HasSuffix(strings.ToLower(strings.TrimSpace(m.Path)), ".strm") || + strings.TrimSpace(m.STRMURL) != "" if m.Height > 0 { parts = append(parts, fmt.Sprintf("%dp", m.Height)) } else if m.Width > 0 { @@ -368,17 +366,91 @@ func MediaVersionLabel(m model.Media) string { if codec := strings.TrimSpace(m.VideoCodec); codec != "" { parts = append(parts, strings.ToUpper(codec)) } - if m.SizeBytes > 0 { + // STRM 占位体积(通常几十到几百字节)不能区分分片,跳过。 + placeholderSize := isStrm && m.SizeBytes > 0 && m.SizeBytes < 1024*1024 + if m.SizeBytes > 0 && !placeholderSize { parts = append(parts, formatMediaSize(m.SizeBytes)) } - if len(parts) > 0 { + if len(parts) > 0 && !mediaVersionLabelIndistinct(parts) { return strings.Join(parts, " · ") } - base := filepath.Base(strings.ReplaceAll(strings.TrimSpace(m.Path), "\\", "/")) - if base == "" || base == "." { - return firstNonEmpty(m.Title, m.OriginalName, m.ID) + if cleaned := cleanMediaVersionFilename(m.Path); cleaned != "" { + return cleaned } - return base + return firstNonEmpty(m.Title, m.OriginalName, m.ID) +} + +func mediaVersionLabelIndistinct(parts []string) bool { + if len(parts) == 0 { + return true + } + for _, part := range parts { + upper := strings.ToUpper(strings.TrimSpace(part)) + if upper == "" || upper == "云端直链" || upper == "STRM" { + continue + } + if _, ok := videoExtensions["."+strings.ToLower(upper)]; ok { + continue + } + return false + } + return true +} + +func cleanMediaVersionFilename(path string) string { + base := filepath.Base(strings.ReplaceAll(strings.TrimSpace(path), "\\", "/")) + if base == "" || base == "." { + return "" + } + stem := mediaFileStem(base) + stem = strings.ReplaceAll(stem, "_", " ") + stem = strings.ReplaceAll(stem, ".", " ") + return strings.Join(strings.Fields(stem), " ") +} + +// mediaPartNumber 从路径提取分片序号(part1 / cd2 / -3)。 +// 裸后缀只认 1–2 位数字,避免把年份 2024 当成 part。 +func mediaPartNumber(path string) (int, bool) { + stem := mediaFileStem(filepath.Base(strings.ReplaceAll(strings.TrimSpace(path), "\\", "/"))) + if stem == "" { + return 0, false + } + lower := strings.ToLower(stem) + for _, re := range mediaPartNumberPatterns { + if m := re.FindStringSubmatch(lower); len(m) == 2 { + n, err := strconv.Atoi(m[1]) + if err == nil && n > 0 { + return n, true + } + } + } + return 0, false +} + +var mediaPartNumberPatterns = []*regexp.Regexp{ + regexp.MustCompile(`(?:^|[.\-_])part[\s._-]*(\d{1,3})$`), + regexp.MustCompile(`(?:^|[.\-_])(?:cd|disc|disk)[\s._-]*(\d{1,3})$`), + regexp.MustCompile(`[-_](\d{1,2})$`), +} + +func sortMediaVersionsForDisplay(rows []model.Media) { + sort.SliceStable(rows, func(i, j int) bool { + return compareMediaVersionsForDisplay(rows[i], rows[j]) + }) +} + +// compareMediaVersionsForDisplay:有分片号时按 1→2→3… 升序;否则按画质/体积择优。 +func compareMediaVersionsForDisplay(a, b model.Media) bool { + pa, oka := mediaPartNumber(a.Path) + pb, okb := mediaPartNumber(b.Path) + if oka && okb { + if pa != pb { + return pa < pb + } + } else if oka != okb { + return oka + } + return betterMediaVersion(a, b) } func mediaVersionContainerFromPath(path, strmURL string) string { diff --git a/internal/service/media_versions_test.go b/internal/service/media_versions_test.go index e3ab742..752b121 100644 --- a/internal/service/media_versions_test.go +++ b/internal/service/media_versions_test.go @@ -443,6 +443,88 @@ func TestMediaVersionLabelUsesContainerAndSize(t *testing.T) { } } +func TestMediaPartNumberFromPath(t *testing.T) { + cases := []struct { + path string + want int + ok bool + }{ + {"/media/云下载/ipvr00192pl/fbzip.com@ipvr00192.part1.strm", 1, true}, + {"/media/云下载/ipvr00192pl/fbzip.com@ipvr00192.part2.strm", 2, true}, + {"/media/云下载/sivr-270/sivr-270-1.strm", 1, true}, + {"/media/云下载/sivr-270/sivr-270-7.strm", 7, true}, + {"/media/云下载/fc2/FC2PPV-4701981-cd2.strm", 2, true}, + {"/media/Movies/Inception.2010.1080p.BluRay.x264.mkv", 0, false}, + {"/media/Movies/Movie.2024.mkv", 0, false}, + } + for _, tc := range cases { + got, ok := mediaPartNumber(tc.path) + if ok != tc.ok || got != tc.want { + t.Fatalf("mediaPartNumber(%q) = (%d, %v), want (%d, %v)", tc.path, got, ok, tc.want, tc.ok) + } + } +} + +func TestGroupMediaVersionsSortsPartsAscending(t *testing.T) { + rows := []model.Media{ + { + Base: model.Base{ID: "p3"}, + LibraryID: "cloud", + Title: "SIVR-270", + Path: "/media/云下载/sivr-270/sivr-270-3.strm", + Year: 2023, + NSFW: true, + SizeBytes: 300, + }, + { + Base: model.Base{ID: "p1"}, + LibraryID: "cloud", + Title: "SIVR-270", + Path: "/media/云下载/sivr-270/sivr-270-1.strm", + Year: 2023, + NSFW: true, + SizeBytes: 100, + }, + { + Base: model.Base{ID: "p2"}, + LibraryID: "cloud", + Title: "SIVR-270", + Path: "/media/云下载/sivr-270/sivr-270-2.strm", + Year: 2023, + NSFW: true, + SizeBytes: 200, + }, + } + grouped := groupMediaVersions(rows) + if len(grouped) != 1 || len(grouped[0].Versions) != 3 { + t.Fatalf("grouped = %#v", grouped) + } + want := []string{"p1", "p2", "p3"} + for i, id := range want { + if grouped[0].Versions[i].ID != id { + t.Fatalf("versions[%d] = %q, want %q (order %#v)", i, grouped[0].Versions[i].ID, id, grouped[0].Versions) + } + } + if grouped[0].ID != "p1" { + t.Fatalf("primary = %q, want p1 (first part)", grouped[0].ID) + } +} + +func TestMediaVersionLabelFallsBackToFilenameWhenIndistinct(t *testing.T) { + label := MediaVersionLabel(model.Media{ + Title: "SIVR-270-【VR】河北彩花", + Path: "/media/云下载/sivr-270/sivr-270-1.strm", + SizeBytes: 157, + STRMURL: "/api/strm/play/cloud115/video.mp4?pickcode=a", + }) + if !strings.Contains(label, "sivr-270-1") { + t.Fatalf("indistinct strm label should fall back to filename, got %q", label) + } + if strings.EqualFold(label, "MP4") || strings.Contains(label, "云端") { + t.Fatalf("should not keep generic-only label %q", label) + } +} + // SIVR-270 现场:一部分分片走在线刮削(MetaTube 把番号/provider 借用进 // douban_id/thetvdb_id),另一部分只从本地 NFO 拿到标题(没有外部 ID)。 // 按外部 ID 分组会把它们裂成两张标题完全相同的卡,必须按番号折成一张。 @@ -566,6 +648,58 @@ func TestGroupMediaVersionsMergesAdultPartsWithBorrowedMetaTubeIDs(t *testing.T) if len(grouped[0].Versions) != 2 { t.Fatalf("versions len = %d, want 2", len(grouped[0].Versions)) } + if grouped[0].Versions[0].ID != "ipvr-00192-part1" || grouped[0].Versions[1].ID != "ipvr-00192-part2" { + t.Fatalf("versions should be part1 then part2, got %#v", grouped[0].Versions) + } +} + +func TestGetMediaItemListsAdultVersionsAcrossMetadataSources(t *testing.T) { + db := newServiceTestDB(t, &model.Library{}, &model.Media{}) + repos := repository.New(db) + lib := model.Library{Name: "云下载", Path: "/media/云下载", Type: "movie", Enabled: true} + if err := repos.Library.Create(t.Context(), &lib); err != nil { + t.Fatal(err) + } + rows := []model.Media{ + { + // 走 MetaTube 刮削的分片:番号/provider 被借用进 douban/thetvdb。 + LibraryID: lib.ID, + Title: "SIVR-270-【VR】河北彩花", + OriginalName: "SIVR-270", + Path: "/media/云下载/sivr-270/sivr-270-1.strm", + Year: 2023, + NSFW: true, + DoubanID: "SIVR-270", + TheTVDBID: "JavBus", + ScrapeStatus: "matched", + SizeBytes: 200, + }, + { + // 只有本地 NFO 身份的分片:两个外部 ID 都为空。 + LibraryID: lib.ID, + Title: "SIVR-270-【VR】河北彩花", + OriginalName: "SIVR-270", + Path: "/media/云下载/sivr-270/sivr-270-4.strm", + Year: 2023, + NSFW: true, + ScrapeStatus: "matched", + SizeBytes: 100, + }, + } + if err := db.Create(&rows).Error; err != nil { + t.Fatal(err) + } + + svc := NewMediaService(&config.Config{}, zap.NewNop(), repos). + SetRuntimeCache(NewRuntimeCacheService(&config.Config{}, zap.NewNop())) + item, err := svc.GetMediaItem(t.Context(), rows[0].ID) + if err != nil { + t.Fatal(err) + } + if item == nil || len(item.Versions) != 2 { + // 详情页版本列表不能只按外部 ID 收窄,否则没有 ID 的分片会消失。 + t.Fatalf("versions = %#v, want both parts", item) + } } func TestMediaAdultGroupCodeRequiresNSFW(t *testing.T) { diff --git a/web/src/utils/mediaVersion.test.ts b/web/src/utils/mediaVersion.test.ts new file mode 100644 index 0000000..2696f0c --- /dev/null +++ b/web/src/utils/mediaVersion.test.ts @@ -0,0 +1,44 @@ +import { mediaVersionLabel } from './mediaVersion.ts' +import type { Media } from '../types/index.ts' + +function check(name: string, condition: boolean) { + if (!condition) throw new Error(`mediaVersion: ${name}`) +} + +function media(partial: Partial): Media { + return { + id: 'm1', + title: 'SIVR-270', + path: '', + height: 0, + width: 0, + size_bytes: 0, + ...partial, + } as Media +} + +const partLabel = mediaVersionLabel(media({ + path: '/media/云下载/sivr-270/sivr-270-1.strm', + size_bytes: 157, + strm_url: '/api/strm/play/cloud115/video.mp4?pickcode=a', +})) +check('indistinct strm falls back to filename', partLabel.includes('sivr-270-1')) +check('indistinct strm is not generic MP4 only', partLabel.toUpperCase() !== 'MP4') + +const richLabel = mediaVersionLabel(media({ + path: '/strm/竞女01.mkv.strm', + height: 1080, + size_bytes: 1024 * 1024 * 1200, + strm_url: '/api/strm/play/cloud115/video.mkv?pickcode=a', +})) +check('rich metadata keeps resolution label', richLabel.includes('1080p')) +check('rich metadata keeps container', richLabel.toUpperCase().includes('MKV')) + +const dottedPart = mediaVersionLabel(media({ + path: '/media/云下载/ipvr00192pl/fbzip.com@ipvr00192.part2.strm', + size_bytes: 157, + strm_url: '/api/strm/play/cloud115/video.mp4?pickcode=b', +})) +check('part2 filename fallback keeps part token', dottedPart.includes('part2')) + +console.log('mediaVersion.test.ts: ok') diff --git a/web/src/utils/mediaVersion.ts b/web/src/utils/mediaVersion.ts index 26fbbea..599220e 100644 --- a/web/src/utils/mediaVersion.ts +++ b/web/src/utils/mediaVersion.ts @@ -107,6 +107,15 @@ function cleanFallbackFilename(raw: string): string { return name } +function isIndistinctVersionLabel(parts: string[]): boolean { + if (parts.length === 0) return true + return parts.every((part) => { + const upper = part.toUpperCase() + if (upper === '云端直链' || upper === 'STRM') return true + return VIDEO_EXTENSIONS.has(part.toLowerCase()) + }) +} + /** 版本切换展示名:分辨率 · 容器 · 编码 · 体积 / 规格 / 来源标签。 */ export function mediaVersionLabel(media: Media): string { const isStrm = isStrmMedia(media) @@ -153,11 +162,11 @@ export function mediaVersionLabel(media: Media): string { parts.push('云端直链') } - if (parts.length > 0) { + // 只有容器 /「云端直链」时无法区分多分片,回退文件名(sivr-270-1 / …part1) + if (parts.length > 0 && !isIndistinctVersionLabel(parts)) { return parts.join(' · ') } - // 兜底:使用清理后的文件名 const cleaned = cleanFallbackFilename(media.path || '') return cleaned || media.title || media.id }