From ebf791c43684c7036d4f48be772ee97cef425fc5 Mon Sep 17 00:00:00 2001 From: truewhile <62226914+truewhile@users.noreply.github.com> Date: Thu, 17 Sep 2026 01:29:10 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/service/media_listing.go | 15 ++++- internal/service/media_versions.go | 67 +++++++++++++++++-- .../media_versions_adult_split_test.go | 66 ++++++++++++++++++ 3 files changed, 138 insertions(+), 10 deletions(-) create mode 100644 internal/service/media_versions_adult_split_test.go diff --git a/internal/service/media_listing.go b/internal/service/media_listing.go index 55a9975..11cc696 100644 --- a/internal/service/media_listing.go +++ b/internal/service/media_listing.go @@ -182,10 +182,13 @@ func (s *MediaService) listVersionSiblings(ctx context.Context, media *model.Med // 没有 douban_id/thetvdb_id(MetaTube 刮削出来的那几个才有)。若仍按外部 // ID 预先收窄候选集,同番号的其它分片会被 SQL 直接排除,表现就是库里折叠 // 出了 N 个版本、详情页却只列出带 ID 的那几个。因此成人条目不预先收窄, - // 直接在该库范围内比对版本键。 + // 直接在该库范围内比对版本键。未刮削但文件名带番号的分片同理:同一部片的 + // 各分片元数据来源不一致(刮削 vs pending),收窄后标题也对不上。 var candidates []model.Media narrowed := false - if mediaAdultGroupCode(*media) == "" { + hasAdultCode := mediaAdultGroupCode(*media) != "" || + canonicalAdultGroupCode(AdultCodeFromMediaPath(media.Path)) != "" + if !hasAdultCode { candidates, narrowed, err = s.repo.Media.ListVersionCandidates(ctx, libraryIDs, *media, 5000) if err != nil { return nil, err @@ -198,9 +201,15 @@ func (s *MediaService) listVersionSiblings(ctx context.Context, media *model.Med } } s.attachLibraryMetadata(ctx, candidates) + // 同番号只要有一个分片被确认为成人内容,尚未刮削的分片也按番号折叠, + // 与列表页的分组结果保持一致。 + vouched := adultCodesVouchedByNSFW(candidates) + if vouchKey := adultVouchedGroupKey(*media, vouched); vouchKey != "" { + key = vouchKey + } matched := make([]model.Media, 0, 4) for _, row := range candidates { - if mediaVersionGroupKey(row) == key { + if mediaVersionGroupKeyWithAdultVouch(row, vouched) == key { matched = append(matched, row) } } diff --git a/internal/service/media_versions.go b/internal/service/media_versions.go index 8d5d590..c0bdbb8 100644 --- a/internal/service/media_versions.go +++ b/internal/service/media_versions.go @@ -77,6 +77,10 @@ func groupMediaVersions(items []model.Media) []MediaItem { if len(items) == 0 { return nil } + // 同一番号只要有一个分片被刮削标记为成人内容,就认定整部片都是成人条目: + // 未刮削的分片(nsfw=false)也按番号折叠。否则「一个分片刮削成功、其余仍 + // pending」时,已刮削的走番号分组、其余落到标题分组,整部片被拆成两张卡。 + adultCodes := adultCodesVouchedByNSFW(items) type group struct { key string rows []model.Media @@ -84,7 +88,7 @@ func groupMediaVersions(items []model.Media) []MediaItem { groups := make([]group, 0, len(items)) byKey := make(map[string]int, len(items)) for _, item := range items { - key := mediaVersionGroupKey(item) + key := mediaVersionGroupKeyWithAdultVouch(item, adultCodes) if key == "" { groups = append(groups, group{rows: []model.Media{item}}) continue @@ -135,16 +139,65 @@ func GroupEpisodeVersionsForDisplay(items []model.Media) []MediaItem { return grouped } -func mediaVersionGroupKey(m model.Media) string { - // 远程 Emby 挂载条目保持独立,不与其它远程条目或本地条目折叠合并。 - if IsEmbyRemoteID(m.ID) { - return fmt.Sprintf("embyremote:%s", m.ID) - } - +// mediaVersionLibraryKey 返回版本身份里使用的库标识。 +func mediaVersionLibraryKey(m model.Media) string { libKey := strings.ToLower(strings.TrimSpace(m.LibraryID)) if libKey == "" { libKey = strings.ToLower(strings.TrimSpace(m.DisplayLibraryID)) } + return libKey +} + +// adultCodesVouchedByNSFW 收集「已被刮削确认为成人内容」的番号。 +func adultCodesVouchedByNSFW(items []model.Media) map[string]bool { + codes := map[string]bool{} + for _, item := range items { + if !item.NSFW { + continue + } + if code := canonicalAdultGroupCode(AdultCodeFromMediaPath(item.Path)); code != "" { + codes[code] = true + continue + } + if code := mediaAdultGroupCode(item); code != "" { + codes[code] = true + } + } + return codes +} + +// mediaVersionGroupKeyWithAdultVouch 是版本身份的统一入口:番号已被同组分片 +// 确认时优先按番号折叠,否则沿用 mediaVersionGroupKey 的原判定。 +func mediaVersionGroupKeyWithAdultVouch(m model.Media, vouched map[string]bool) string { + if key := adultVouchedGroupKey(m, vouched); key != "" { + return key + } + return mediaVersionGroupKey(m) +} + +// adultVouchedGroupKey 在番号已被同组分片确认时,返回番号分组键; +// 其余情况返回空串,表示沿用 mediaVersionGroupKey 的原判定。 +func adultVouchedGroupKey(m model.Media, vouched map[string]bool) string { + if len(vouched) == 0 || m.NSFW { + return "" + } + code := canonicalAdultGroupCode(AdultCodeFromMediaPath(m.Path)) + if code == "" || !vouched[code] { + return "" + } + libKey := mediaVersionLibraryKey(m) + if libKey == "" { + libKey = "_" + } + return fmt.Sprintf("adult:%s:%s", libKey, code) +} + +func mediaVersionGroupKey(m model.Media) string { // 远程 Emby 挂载条目保持独立,不与其它远程条目或本地条目折叠合并。 + if IsEmbyRemoteID(m.ID) { + return fmt.Sprintf("embyremote:%s", m.ID) + } + + libKey := mediaVersionLibraryKey(m) specialKind := mediaSpecialKind(m.Path) season, episode := m.SeasonNum, m.EpisodeNum if specialKind != "" && specialKind != mediaSpecialTheatrical && episode <= 0 { diff --git a/internal/service/media_versions_adult_split_test.go b/internal/service/media_versions_adult_split_test.go new file mode 100644 index 0000000..38d5b2e --- /dev/null +++ b/internal/service/media_versions_adult_split_test.go @@ -0,0 +1,66 @@ +package service + +import ( + "strconv" + "testing" + + "github.com/truewhile/MeBox/internal/model" +) + +// 真实场景:同一部片的分片里只有第 4 个被刮削成功(nsfw=true),其余仍是 pending。 +// 修复前:已刮削的分片走「番号」分组、其余落到「标题」分组,7 个分片会裂成两张卡 +// (一张只有第 4 个,另一张是其余 6 个)。 +func TestGroupMediaVersionsKeepsUnscrapedPartsWithScrapedSibling(t *testing.T) { + const libID = "lib-1" + const dir = "/media/云下载/sivr-270/" + + parts := func(nums ...int) []model.Media { + out := make([]model.Media, 0, len(nums)) + for _, n := range nums { + out = append(out, model.Media{ + LibraryID: libID, + Title: "sivr 270", + Path: dir + "sivr-270-" + strconv.Itoa(n) + ".strm", + Container: "strm", + }) + } + return out + } + + items := parts(1, 2, 3) + items = append(items, model.Media{ + LibraryID: libID, + Title: "SIVR-270-【VR】河北彩花×ご奉仕ナース", + OriginalName: "SIVR-270", + Path: dir + "sivr-270-4.strm", + NSFW: true, + ScrapeStatus: "matched", + Container: "strm", + }) + items = append(items, parts(5, 6, 7)...) + + grouped := GroupMediaVersions(items) + if len(grouped) != 1 { + paths := make([]string, 0, len(grouped)) + for _, g := range grouped { + paths = append(paths, g.Path) + } + t.Fatalf("分成 %d 张卡 %v,期望折叠成 1 张", len(grouped), paths) + } + if len(grouped[0].Versions) != 7 { + t.Fatalf("版本数 = %d,期望 7", len(grouped[0].Versions)) + } +} + +// 反向保护:没有任何分片被确认为成人内容时,不能仅凭文件名里的番号形态折叠, +// 否则普通媒体也可能被误并进同一张卡。 +func TestGroupMediaVersionsIgnoresUnvouchedAdultCode(t *testing.T) { + items := []model.Media{ + {LibraryID: "lib-2", Title: "Alpha Movie", Path: "/media/movies/Alpha Movie/ABC-123-1080p.mkv"}, + {LibraryID: "lib-2", Title: "Beta Movie", Path: "/media/movies/Beta Movie/ABC-123-2160p.mkv"}, + } + grouped := GroupMediaVersions(items) + if len(grouped) != 2 { + t.Fatalf("分成 %d 张卡,期望 2 张(未被确认成人内容的番号不参与折叠)", len(grouped)) + } +}