fix(media): stabilize series grouping across library layouts

This commit is contained in:
ShukeBta
2026-08-10 19:16:19 +08:00
parent 5c6fe91742
commit f436267572
4 changed files with 152 additions and 15 deletions
+35 -1
View File
@@ -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 "未命名节目"
}
+38 -4
View File
@@ -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)
}
+22
View File
@@ -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)
}
}