This commit is contained in:
truewhile
2026-09-16 23:36:03 +08:00
parent fc89d23867
commit d283d39008
4 changed files with 304 additions and 14 deletions
+51
View File
@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"path/filepath" "path/filepath"
"sort" "sort"
"strconv"
"strings" "strings"
"github.com/truewhile/MeBox/internal/model" "github.com/truewhile/MeBox/internal/model"
@@ -193,6 +194,18 @@ func mediaVersionGroupKey(m model.Media) string {
return mediaVersionStemGroupKey(m, libKey) 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 { switch {
case m.TMDbID > 0: case m.TMDbID > 0:
if libKey != "" { if libKey != "" {
@@ -267,6 +280,44 @@ func mediaVersionGroupKey(m model.Media) string {
return fmt.Sprintf("movie:%s:%d", title, year) 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 按「库 + 父目录 + 文件词干」折叠多版本 // mediaVersionStemGroupKey 按「库 + 父目录 + 文件词干」折叠多版本
// (如 竞女01.mkv.strm 与 竞女01.mp4.strm)。 // (如 竞女01.mkv.strm 与 竞女01.mp4.strm)。
func mediaVersionStemGroupKey(m model.Media, libKey string) string { func mediaVersionStemGroupKey(m model.Media, libKey string) string {
+161
View File
@@ -442,3 +442,164 @@ func TestMediaVersionLabelUsesContainerAndSize(t *testing.T) {
t.Fatalf("unexpected label %q", label) 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)
}
})
}
}
+44 -14
View File
@@ -18,6 +18,7 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
"go.uber.org/zap" "go.uber.org/zap"
@@ -48,9 +49,13 @@ type movieNFO struct {
Poster string `xml:"thumb,omitempty"` Poster string `xml:"thumb,omitempty"`
Fanart string `xml:"fanart,omitempty"` Fanart string `xml:"fanart,omitempty"`
TMDb int `xml:"tmdbid,omitempty"` TMDb int `xml:"tmdbid,omitempty"`
Genre []string `xml:"genre,omitempty"` // UniqueIDs 让 sidecar 能把 MeBox 自己用的外部 ID 原样带回来。缺了它,
Country []string `xml:"country,omitempty"` // 「在线刮削 → 写 NFO → 重扫读 NFO」会丢掉 douban/thetvdb(成人条目借用
Language []string `xml:"language,omitempty"` // 这两个字段存番号/provider),同一部片的两条来源就会算出不同的版本键。
UniqueIDs []nfoUniqueID `xml:"uniqueid,omitempty"`
Genre []string `xml:"genre,omitempty"`
Country []string `xml:"country,omitempty"`
Language []string `xml:"language,omitempty"`
} }
type episodeNFO struct { type episodeNFO struct {
@@ -167,17 +172,18 @@ func WriteMediaNFO(m *model.Media) (string, error) {
} }
} }
doc = movieNFO{ doc = movieNFO{
Title: title, Title: title,
Original: original, Original: original,
Year: m.Year, Year: m.Year,
Plot: m.Overview, Plot: m.Overview,
Rating: m.Rating, Rating: m.Rating,
Poster: m.PosterURL, Poster: m.PosterURL,
Fanart: m.BackdropURL, Fanart: m.BackdropURL,
TMDb: m.TMDbID, TMDb: m.TMDbID,
Genre: splitNFOList(m.Genres), UniqueIDs: nfoExternalUniqueIDs(m),
Country: splitNFOList(m.Countries), Genre: splitNFOList(m.Genres),
Language: splitNFOList(m.Languages), Country: splitNFOList(m.Countries),
Language: splitNFOList(m.Languages),
} }
} }
out, err := xml.MarshalIndent(doc, "", " ") out, err := xml.MarshalIndent(doc, "", " ")
@@ -202,3 +208,27 @@ func splitNFOList(value string) []string {
} }
return out return out
} }
// nfoExternalUniqueIDs 把 MeBox 使用的外部 ID 写进 sidecar,供自己重扫时读回
// (metadataFromDoc 会按 type 还原成 bangumi/douban/thetvdb 字段)。
// TMDb 走 <tmdbid>,单集 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
}
+48
View File
@@ -100,3 +100,51 @@ func TestWriteMediaNFOAdultTitleWithCode(t *testing.T) {
t.Fatalf("expected originaltitle with code, got:\n%s", text) 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{
`<uniqueid type="douban">SIVR-270</uniqueid>`,
`<uniqueid type="thetvdb">JavBus</uniqueid>`,
} {
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)
}
}