mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-08 14:26:37 +08:00
fix(media): stabilize series grouping across library layouts
This commit is contained in:
@@ -17,6 +17,9 @@ func mediaSeriesKey(media model.Media) string {
|
||||
|
||||
func mediaSeriesRawKey(media model.Media) string {
|
||||
fromPath := seriesTitleFromMediaPath(media.Path)
|
||||
if seriesTitleIsGenericContainer(fromPath, media) {
|
||||
fromPath = ""
|
||||
}
|
||||
if media.SeasonNum > 0 || media.EpisodeNum > 0 || episodicPathRE.MatchString(media.Path+" "+media.DisplayLibraryPath+" "+media.LibraryPath) {
|
||||
if fromPath != "" {
|
||||
return seriesFingerprint("library-path", mediaTargetLibraryID(media), fromPath)
|
||||
@@ -56,6 +59,34 @@ func mediaSeriesRawKey(media model.Media) string {
|
||||
return seriesFingerprint("library-title", media.LibraryID, normalizeSeriesTitle(media.Title))
|
||||
}
|
||||
|
||||
// A flat library such as /media/电视剧 may contain many unrelated episodes.
|
||||
// Treating the category/root folder as the show title collapses all of them
|
||||
// into one giant collection, so generic container names must not be used as a
|
||||
// path identity.
|
||||
func seriesTitleIsGenericContainer(title string, media model.Media) bool {
|
||||
title = normalizeSeriesTitle(title)
|
||||
if title == "" {
|
||||
return false
|
||||
}
|
||||
switch title {
|
||||
case "movie", "movies", "film", "films", "tv", "series", "show", "shows", "anime", "animation", "variety",
|
||||
"电视剧", "剧集", "连续剧", "短剧", "国产剧", "国剧", "欧美剧", "美剧", "英剧", "日韩剧", "日剧", "韩剧", "港剧", "台剧", "港台剧",
|
||||
"综艺", "纪录片", "儿童", "动漫", "番剧", "国漫", "日番", "韩漫", "美漫", "欧美动漫", "欧美动画", "其他动漫", "电影", "成人", "未分类":
|
||||
return true
|
||||
}
|
||||
for _, libraryPath := range []string{media.DisplayLibraryPath, media.LibraryPath} {
|
||||
libraryPath = strings.TrimSpace(libraryPath)
|
||||
if libraryPath == "" {
|
||||
continue
|
||||
}
|
||||
base := pathBaseSlash(libraryPath)
|
||||
if normalizeSeriesTitle(base) == title {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func seriesFingerprint(parts ...string) string {
|
||||
return strings.Join(parts, "\x1f")
|
||||
}
|
||||
@@ -155,7 +186,7 @@ func seriesPathPartLooksLikeFile(part string) bool {
|
||||
}
|
||||
|
||||
func seriesDisplayTitle(media model.Media) string {
|
||||
if fromPath := seriesTitleFromMediaPath(media.Path); fromPath != "" {
|
||||
if fromPath := seriesTitleFromMediaPath(media.Path); fromPath != "" && !seriesTitleIsGenericContainer(fromPath, media) {
|
||||
return fromPath
|
||||
}
|
||||
if media.Title != "" {
|
||||
@@ -164,6 +195,9 @@ func seriesDisplayTitle(media model.Media) string {
|
||||
if media.OriginalName != "" {
|
||||
return media.OriginalName
|
||||
}
|
||||
if fromFile, _ := CleanQuery(media.Path); strings.TrimSpace(fromFile) != "" && !unsafeAutomaticEpisodeQuery(fromFile) {
|
||||
return fromFile
|
||||
}
|
||||
return "未命名节目"
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ type mediaSeriesKeyResolver struct {
|
||||
pathCounts map[string]int
|
||||
externalCounts map[string]int
|
||||
titleCounts map[string]int
|
||||
pathTitles map[string]string
|
||||
}
|
||||
|
||||
func newMediaSeriesKeyResolver(items []model.Media) mediaSeriesKeyResolver {
|
||||
@@ -18,6 +19,7 @@ func newMediaSeriesKeyResolver(items []model.Media) mediaSeriesKeyResolver {
|
||||
pathCounts: make(map[string]int),
|
||||
externalCounts: make(map[string]int),
|
||||
titleCounts: make(map[string]int),
|
||||
pathTitles: make(map[string]string),
|
||||
}
|
||||
for _, item := range items {
|
||||
if !mediaLooksEpisodicForGrouping(item) {
|
||||
@@ -33,20 +35,52 @@ func newMediaSeriesKeyResolver(items []model.Media) mediaSeriesKeyResolver {
|
||||
resolver.titleCounts[key]++
|
||||
}
|
||||
}
|
||||
// A scraper can normalize the same show to one title while the source
|
||||
// release folders still contain different tags (1080p/2160p, uploader
|
||||
// names, etc.). Remember an unambiguous title alias for each path group so
|
||||
// those folders are bridged instead of rendered as separate collections.
|
||||
pathTitleCandidates := make(map[string]map[string]struct{})
|
||||
for _, item := range items {
|
||||
if !mediaLooksEpisodicForGrouping(item) {
|
||||
continue
|
||||
}
|
||||
pathKey := mediaSeriesRawKey(item)
|
||||
titleKey := repeatedSeriesTitleKey(item)
|
||||
if !strings.HasPrefix(pathKey, "library-path") || titleKey == "" || resolver.titleCounts[titleKey] < 2 {
|
||||
continue
|
||||
}
|
||||
if pathTitleCandidates[pathKey] == nil {
|
||||
pathTitleCandidates[pathKey] = make(map[string]struct{})
|
||||
}
|
||||
pathTitleCandidates[pathKey][titleKey] = struct{}{}
|
||||
}
|
||||
for pathKey, candidates := range pathTitleCandidates {
|
||||
if len(candidates) != 1 {
|
||||
continue
|
||||
}
|
||||
for titleKey := range candidates {
|
||||
resolver.pathTitles[pathKey] = titleKey
|
||||
}
|
||||
}
|
||||
return resolver
|
||||
}
|
||||
|
||||
func (r mediaSeriesKeyResolver) key(media model.Media) string {
|
||||
if mediaLooksEpisodicForGrouping(media) {
|
||||
if key := mediaSeriesRawKey(media); strings.HasPrefix(key, "library-path") && r.pathCounts[key] > 1 {
|
||||
if key := repeatedSeriesTitleKey(media); key != "" && r.titleCounts[key] > 1 {
|
||||
return compactSeriesKey(key)
|
||||
}
|
||||
if pathKey := mediaSeriesRawKey(media); strings.HasPrefix(pathKey, "library-path") {
|
||||
if titleKey := r.pathTitles[pathKey]; titleKey != "" {
|
||||
return compactSeriesKey(titleKey)
|
||||
}
|
||||
if r.pathCounts[pathKey] > 1 {
|
||||
return compactSeriesKey(pathKey)
|
||||
}
|
||||
}
|
||||
if key := repeatedSeriesExternalKey(media); key != "" && r.externalCounts[key] > 1 {
|
||||
return compactSeriesKey(key)
|
||||
}
|
||||
if key := repeatedSeriesTitleKey(media); key != "" && r.titleCounts[key] > 1 {
|
||||
return compactSeriesKey(key)
|
||||
}
|
||||
}
|
||||
return mediaSeriesKey(media)
|
||||
}
|
||||
|
||||
@@ -423,3 +423,25 @@ func TestGroupMediaSeriesCardsDoesNotCollideMovieAndTVExternalIDs(t *testing.T)
|
||||
t.Fatalf("cards=%#v, want movie and TV item kept separate despite equal numeric TMDb IDs", cards)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaSeriesKeyDoesNotUseGenericLibraryFolderAsSeriesTitle(t *testing.T) {
|
||||
items := []model.Media{
|
||||
{LibraryID: "tv", Path: `/media/电视剧/Alpha Show.S01E01.mkv`, SeasonNum: 1, EpisodeNum: 1},
|
||||
{LibraryID: "tv", Path: `/media/电视剧/Beta Show.S01E01.mkv`, SeasonNum: 1, EpisodeNum: 1},
|
||||
}
|
||||
cards := groupMediaSeriesCards(items)
|
||||
if len(cards) != 2 {
|
||||
t.Fatalf("cards=%#v, want two independent series from a flat library root", cards)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupMediaSeriesCardsBridgesReleaseFoldersByMatchedSeriesTitle(t *testing.T) {
|
||||
items := []model.Media{
|
||||
{LibraryID: "tv", Title: "同一部剧", ScrapeStatus: "matched", Path: `/media/电视剧/同一部剧 版本甲/Season 01/ep1.mkv`, SeasonNum: 1, EpisodeNum: 1},
|
||||
{LibraryID: "tv", Title: "同一部剧", ScrapeStatus: "matched", Path: `/media/电视剧/同一部剧 版本乙/Season 01/ep2.mkv`, SeasonNum: 1, EpisodeNum: 2},
|
||||
}
|
||||
cards := groupMediaSeriesCards(items)
|
||||
if len(cards) != 1 || cards[0].Count != 2 {
|
||||
t.Fatalf("cards=%#v, want one series bridged by matched title", cards)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user