From d283d390085486ffe6b71d0c115aa30bd9720034 Mon Sep 17 00:00:00 2001 From: truewhile <62226914+truewhile@users.noreply.github.com> Date: Wed, 16 Sep 2026 23:36:03 +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_versions.go | 51 ++++++++ internal/service/media_versions_test.go | 161 ++++++++++++++++++++++++ internal/service/nfo.go | 58 ++++++--- internal/service/nfo_test.go | 48 +++++++ 4 files changed, 304 insertions(+), 14 deletions(-) diff --git a/internal/service/media_versions.go b/internal/service/media_versions.go index ecab7ac..5eb038b 100644 --- a/internal/service/media_versions.go +++ b/internal/service/media_versions.go @@ -4,6 +4,7 @@ import ( "fmt" "path/filepath" "sort" + "strconv" "strings" "github.com/truewhile/MeBox/internal/model" @@ -193,6 +194,18 @@ func mediaVersionGroupKey(m model.Media) string { return mediaVersionStemGroupKey(m, libKey) } + // 成人条目按番号折叠。在线刮削(MetaTube)会把番号与 provider「借用」 + // 存进 douban_id/thetvdb_id,本地 NFO 路径则拿不到这两个字段;一旦按 + // 外部 ID 分组,同一部片的各个分片就会因为元数据来源不同(在线刮削 vs + // 本地 NFO)裂成两张标题相同的卡。番号取自原文件名/路径/标题,与元数据 + // 来源无关,因此这里统一用番号做版本身份。 + if code := mediaAdultGroupCode(m); code != "" { + if libKey == "" { + libKey = "_" + } + return fmt.Sprintf("adult:%s:%s", libKey, code) + } + switch { case m.TMDbID > 0: if libKey != "" { @@ -267,6 +280,44 @@ func mediaVersionGroupKey(m model.Media) string { return fmt.Sprintf("movie:%s:%d", title, year) } +// mediaAdultGroupCode 返回成人条目的番号,作为与元数据来源无关的版本身份。 +// +// 只在已经确认是成人条目(NSFW)时才返回番号:番号本身来自原文件名/路径/ +// 标题,不会随「在线刮削(MetaTube 把番号与 provider 借用进 douban_id/ +// thetvdb_id)」还是「本地 NFO(不带外部 ID)」而改变,因此两条来源能算出 +// 同一个键。未标记 NSFW 的条目仍走标题分组,普通影视库里 "The Matrix 1999" +// 这类文件名不会被当成番号。 +func mediaAdultGroupCode(m model.Media) string { + if !m.NSFW { + return "" + } + code := firstText( + normalizeAdultCode(m.OriginalName), + AdultCodeFromMediaPath(m.Path), + normalizeAdultCode(m.Title), + ) + if code == "" { + return "" + } + return canonicalAdultGroupCode(code) +} + +// canonicalAdultGroupCode 去掉番号数字部分的补零,让 IPVR-00192 与 IPVR-192 +// 这类同一部片的不同写法落到同一个分组键上。无法解析数字的番号原样返回 +// (如 FC2-PPV-4701981)。 +func canonicalAdultGroupCode(code string) string { + code = strings.ToUpper(strings.TrimSpace(code)) + prefix, digits, ok := strings.Cut(code, "-") + if !ok { + return code + } + value, err := strconv.Atoi(digits) + if err != nil { + return code + } + return prefix + "-" + strconv.Itoa(value) +} + // mediaVersionStemGroupKey 按「库 + 父目录 + 文件词干」折叠多版本 // (如 竞女01.mkv.strm 与 竞女01.mp4.strm)。 func mediaVersionStemGroupKey(m model.Media, libKey string) string { diff --git a/internal/service/media_versions_test.go b/internal/service/media_versions_test.go index 86f6465..e3ab742 100644 --- a/internal/service/media_versions_test.go +++ b/internal/service/media_versions_test.go @@ -442,3 +442,164 @@ func TestMediaVersionLabelUsesContainerAndSize(t *testing.T) { t.Fatalf("unexpected label %q", label) } } + +// SIVR-270 现场:一部分分片走在线刮削(MetaTube 把番号/provider 借用进 +// douban_id/thetvdb_id),另一部分只从本地 NFO 拿到标题(没有外部 ID)。 +// 按外部 ID 分组会把它们裂成两张标题完全相同的卡,必须按番号折成一张。 +func TestGroupMediaVersionsMergesAdultPartsAcrossMetadataSources(t *testing.T) { + online := model.Media{ + Base: model.Base{ID: "sivr-270-2"}, + LibraryID: "cloud", + Title: "SIVR-270-【VR】河北彩花", + OriginalName: "SIVR-270", + Path: "/media/云下载/sivr-270/sivr-270-2.strm", + Year: 2023, + NSFW: true, + DoubanID: "SIVR-270", + TheTVDBID: "JavBus", + ScrapeStatus: "matched", + SizeBytes: 200, + } + fromNFO := model.Media{ + Base: model.Base{ID: "sivr-270-3"}, + LibraryID: "cloud", + Title: "SIVR-270-【VR】河北彩花", + OriginalName: "SIVR-270", + Path: "/media/云下载/sivr-270/sivr-270-3.strm", + Year: 2023, + NSFW: true, + ScrapeStatus: "matched", + SizeBytes: 100, + } + + grouped := groupMediaVersions([]model.Media{online, fromNFO}) + if len(grouped) != 1 { + t.Fatalf("grouped len = %d, want 1: %#v", len(grouped), grouped) + } + if len(grouped[0].Versions) != 2 { + t.Fatalf("versions len = %d, want 2", len(grouped[0].Versions)) + } + if grouped[0].Path != online.Path { + t.Fatalf("larger part should stay primary, got %q", grouped[0].Path) + } +} + +func TestGroupMediaVersionsKeepsDifferentAdultCodesSeparate(t *testing.T) { + rows := []model.Media{ + { + Base: model.Base{ID: "sivr-270"}, + LibraryID: "cloud", + Title: "SIVR-270 作品", + Path: "/media/云下载/sivr-270/sivr-270-1.strm", + Year: 2023, + NSFW: true, + }, + { + Base: model.Base{ID: "sivr-271"}, + LibraryID: "cloud", + Title: "SIVR-271 作品", + Path: "/media/云下载/sivr-271/sivr-271-1.strm", + Year: 2023, + NSFW: true, + }, + } + grouped := groupMediaVersions(rows) + if len(grouped) != 2 { + t.Fatalf("different adult codes must stay separate: %#v", grouped) + } +} + +// 同一部片的两种写法:路径里的 IPVR-00192 与刮削回来的 IPVR-192 必须归一。 +func TestGroupMediaVersionsMergesAdultCodesIgnoringZeroPadding(t *testing.T) { + scraped := model.Media{ + Base: model.Base{ID: "ipvr-192-part2"}, + LibraryID: "cloud", + Title: "IPVR-192-相沢みなみ", + OriginalName: "IPVR-192", + Path: "/media/云下载/ipvr00192pl/scraped/part2.strm", + Year: 2022, + NSFW: true, + ScrapeStatus: "matched", + SizeBytes: 200, + } + fromPath := model.Media{ + Base: model.Base{ID: "ipvr-00192-part1"}, + LibraryID: "cloud", + Title: "fbzip com@ipvr00192", + Path: "/media/云下载/ipvr00192pl/fbzip.com@ipvr00192.part1.strm", + Year: 2022, + NSFW: true, + SizeBytes: 100, + } + grouped := groupMediaVersions([]model.Media{scraped, fromPath}) + if len(grouped) != 1 || len(grouped[0].Versions) != 2 { + t.Fatalf("zero padded code must fold into one item: %#v", grouped) + } +} + +func TestGroupMediaVersionsMergesAdultPartsWithBorrowedMetaTubeIDs(t *testing.T) { + // IPVR-00192 现场:两个 part 标题、年份完全一样,但 part2 是走 MetaTube + // 刮削的(番号/provider 被借用进 douban_id/thetvdb_id),part1 只有本地 + // NFO 身份、两个字段为空。旧逻辑按外部 ID 分组会裂成两张卡。 + part1 := model.Media{ + Base: model.Base{ID: "ipvr-00192-part1"}, + LibraryID: "cloud", + Title: "IPVR-00192-IPVR-192-【VR】相沢みなみ", + OriginalName: "IPVR-00192", + Path: "/media/云下载/ipvr00192pl/fbzip.com@ipvr00192.part1.strm", + Year: 2022, + NSFW: true, + ScrapeStatus: "matched", + SizeBytes: 100, + } + part2 := part1 + part2.ID = "ipvr-00192-part2" + part2.Path = "/media/云下载/ipvr00192pl/fbzip.com@ipvr00192.part2.strm" + part2.DoubanID = "ipvr00192" + part2.TheTVDBID = "JAV321" + part2.SizeBytes = 200 + + grouped := groupMediaVersions([]model.Media{part1, part2}) + if len(grouped) != 1 { + t.Fatalf("grouped len = %d, want 1: %#v", len(grouped), grouped) + } + if len(grouped[0].Versions) != 2 { + t.Fatalf("versions len = %d, want 2", len(grouped[0].Versions)) + } +} + +func TestMediaAdultGroupCodeRequiresNSFW(t *testing.T) { + cases := []struct { + name string + m model.Media + want string + }{ + { + name: "普通影片名里的字母+数字不当番号", + m: model.Media{Title: "Spider-Man 2008 1080p", Path: "/media/Movies/Spider-Man 2008 1080p.mkv"}, + want: "", + }, + { + name: "未识别成人的条目即使文件名像番号也不折叠", + m: model.Media{Title: "SIVR-270", Path: "/media/云下载/sivr-270/sivr-270-1.strm"}, + want: "", + }, + { + name: "已标记 NSFW 时按番号分组", + m: model.Media{NSFW: true, Title: "sivr 270", Path: "/media/云下载/sivr-270/sivr-270-1.strm"}, + want: "SIVR-270", + }, + { + name: "站点前缀里带补零的番号会归一", + m: model.Media{NSFW: true, Title: "fbzip com@ipvr00192", Path: "/media/云下载/ipvr00192pl/fbzip.com@ipvr00192.part1.strm"}, + want: "IPVR-192", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := mediaAdultGroupCode(tc.m); got != tc.want { + t.Fatalf("mediaAdultGroupCode = %q, want %q", got, tc.want) + } + }) + } +} diff --git a/internal/service/nfo.go b/internal/service/nfo.go index 9e9d8a3..00d0cc0 100644 --- a/internal/service/nfo.go +++ b/internal/service/nfo.go @@ -18,6 +18,7 @@ import ( "fmt" "os" "path/filepath" + "strconv" "strings" "go.uber.org/zap" @@ -48,9 +49,13 @@ type movieNFO struct { Poster string `xml:"thumb,omitempty"` Fanart string `xml:"fanart,omitempty"` TMDb int `xml:"tmdbid,omitempty"` - Genre []string `xml:"genre,omitempty"` - Country []string `xml:"country,omitempty"` - Language []string `xml:"language,omitempty"` + // UniqueIDs 让 sidecar 能把 MeBox 自己用的外部 ID 原样带回来。缺了它, + // 「在线刮削 → 写 NFO → 重扫读 NFO」会丢掉 douban/thetvdb(成人条目借用 + // 这两个字段存番号/provider),同一部片的两条来源就会算出不同的版本键。 + UniqueIDs []nfoUniqueID `xml:"uniqueid,omitempty"` + Genre []string `xml:"genre,omitempty"` + Country []string `xml:"country,omitempty"` + Language []string `xml:"language,omitempty"` } type episodeNFO struct { @@ -167,17 +172,18 @@ func WriteMediaNFO(m *model.Media) (string, error) { } } doc = movieNFO{ - Title: title, - Original: original, - Year: m.Year, - Plot: m.Overview, - Rating: m.Rating, - Poster: m.PosterURL, - Fanart: m.BackdropURL, - TMDb: m.TMDbID, - Genre: splitNFOList(m.Genres), - Country: splitNFOList(m.Countries), - Language: splitNFOList(m.Languages), + Title: title, + Original: original, + Year: m.Year, + Plot: m.Overview, + Rating: m.Rating, + Poster: m.PosterURL, + Fanart: m.BackdropURL, + TMDb: m.TMDbID, + UniqueIDs: nfoExternalUniqueIDs(m), + Genre: splitNFOList(m.Genres), + Country: splitNFOList(m.Countries), + Language: splitNFOList(m.Languages), } } out, err := xml.MarshalIndent(doc, "", " ") @@ -202,3 +208,27 @@ func splitNFOList(value string) []string { } return out } + +// nfoExternalUniqueIDs 把 MeBox 使用的外部 ID 写进 sidecar,供自己重扫时读回 +// (metadataFromDoc 会按 type 还原成 bangumi/douban/thetvdb 字段)。 +// TMDb 走 ,单集 NFO 不写唯一 ID:单集 ID 属于单集,写进整剧身份会把 +// 同一部剧拆成多张卡(见 mergeEpisodeMetadata 的约束说明)。 +func nfoExternalUniqueIDs(m *model.Media) []nfoUniqueID { + if m == nil { + return nil + } + out := make([]nfoUniqueID, 0, 3) + if m.BangumiID > 0 { + out = append(out, nfoUniqueID{Type: "bangumi", Value: strconv.Itoa(m.BangumiID)}) + } + if id := strings.TrimSpace(m.DoubanID); id != "" { + out = append(out, nfoUniqueID{Type: "douban", Value: id}) + } + if id := strings.TrimSpace(m.TheTVDBID); id != "" { + out = append(out, nfoUniqueID{Type: "thetvdb", Value: id}) + } + if len(out) == 0 { + return nil + } + return out +} diff --git a/internal/service/nfo_test.go b/internal/service/nfo_test.go index 3db603a..0981ec9 100644 --- a/internal/service/nfo_test.go +++ b/internal/service/nfo_test.go @@ -100,3 +100,51 @@ func TestWriteMediaNFOAdultTitleWithCode(t *testing.T) { t.Fatalf("expected originaltitle with code, got:\n%s", text) } } + +func TestWriteMediaNFORoundTripsExternalUniqueIDs(t *testing.T) { + root := t.TempDir() + mediaPath := filepath.Join(root, "云下载", "sivr-270", "sivr-270-1.strm") + if err := os.MkdirAll(filepath.Dir(mediaPath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(mediaPath, []byte("strm"), 0o644); err != nil { + t.Fatal(err) + } + + dst, err := WriteMediaNFO(&model.Media{ + Title: "SIVR-270-河北彩花", + OriginalName: "SIVR-270", + Path: mediaPath, + Year: 2023, + NSFW: true, + DoubanID: "SIVR-270", + TheTVDBID: "JavBus", + }) + if err != nil { + t.Fatal(err) + } + body, err := os.ReadFile(dst) + if err != nil { + t.Fatal(err) + } + text := string(body) + // 成人条目把番号/provider 借用存在 douban/thetvdb 两列;sidecar 必须把它们 + // 带回来,否则「在线刮削 → 写 NFO → 重扫读 NFO」之后同一部片会被按不同 + // 分组键拆成多张卡。 + for _, want := range []string{ + `SIVR-270`, + `JavBus`, + } { + if !strings.Contains(text, want) { + t.Fatalf("sidecar missing %s, got:\n%s", want, text) + } + } + + meta, err := ReadLocalMetadata(mediaPath, "", false) + if err != nil { + t.Fatal(err) + } + if meta == nil || meta.DoubanID != "SIVR-270" || meta.TheTVDBID != "JavBus" { + t.Fatalf("external ids did not round trip: %#v", meta) + } +}