mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 03:06:38 +08:00
优化
This commit is contained in:
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user