From f4362675727294869594fd498b5661970e5b1dfc Mon Sep 17 00:00:00 2001 From: ShukeBta <272197458+ShukeBta@users.noreply.github.com> Date: Mon, 10 Aug 2026 19:16:19 +0800 Subject: [PATCH] fix(media): stabilize series grouping across library layouts --- internal/service/media_series_key.go | 36 +++++++++++- internal/service/media_series_resolver.go | 42 ++++++++++++-- internal/service/media_series_test.go | 22 ++++++++ web/src/utils/groupSeries.ts | 67 +++++++++++++++++++---- 4 files changed, 152 insertions(+), 15 deletions(-) diff --git a/internal/service/media_series_key.go b/internal/service/media_series_key.go index efe7c93..aab17ec 100644 --- a/internal/service/media_series_key.go +++ b/internal/service/media_series_key.go @@ -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 "未命名节目" } diff --git a/internal/service/media_series_resolver.go b/internal/service/media_series_resolver.go index c57451f..2ac2ed0 100644 --- a/internal/service/media_series_resolver.go +++ b/internal/service/media_series_resolver.go @@ -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) } diff --git a/internal/service/media_series_test.go b/internal/service/media_series_test.go index eb9577b..2eca31c 100644 --- a/internal/service/media_series_test.go +++ b/internal/service/media_series_test.go @@ -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) + } +} diff --git a/web/src/utils/groupSeries.ts b/web/src/utils/groupSeries.ts index 68168fb..dc4682b 100644 --- a/web/src/utils/groupSeries.ts +++ b/web/src/utils/groupSeries.ts @@ -37,7 +37,8 @@ export function getSeriesKey(media: Media): string { } function getSeriesRawKey(media: Media): string { - const fromPath = seriesTitleFromPath(media.path) + const pathTitle = seriesTitleFromPath(media.path) + const fromPath = seriesTitleIsGenericContainer(pathTitle, media) ? '' : pathTitle if (isEpisodeLike(media) || pathLooksEpisodic(media)) { // 路径剧名优先:对全剧一致, 不受单集 tmdb 污染影响。 if (fromPath) return seriesFingerprint('library-path', targetLibraryID(media), fromPath) @@ -105,8 +106,37 @@ export function isSeriesCard(card: SeriesCard): boolean { export function seriesTitle(media: Media): string { const title = media.title?.trim() - const fromPath = seriesTitleFromPath(media.path) - return (title && !unsafeEpisodeTitle(title) ? title : '') || fromPath || media.original_name || title || '未命名节目' + const pathTitle = seriesTitleFromPath(media.path) + const fromPath = seriesTitleIsGenericContainer(pathTitle, media) ? '' : pathTitle + return (title && !unsafeEpisodeTitle(title) ? title : '') || fromPath || media.original_name || title || seriesTitleFromFilePath(media.path) || '未命名节目' +} + +const GENERIC_SERIES_CONTAINERS = new Set([ + 'movie', 'movies', 'film', 'films', 'tv', 'series', 'show', 'shows', 'anime', 'animation', 'variety', + '电视剧', '剧集', '连续剧', '短剧', '国产剧', '国剧', '欧美剧', '美剧', '英剧', '日韩剧', '日剧', '韩剧', + '港剧', '台剧', '港台剧', '综艺', '纪录片', '儿童', '动漫', '番剧', '国漫', '日番', '韩漫', '美漫', + '欧美动漫', '欧美动画', '其他动漫', '电影', '成人', '未分类', +]) + +function seriesTitleIsGenericContainer(title: string, media: Media): boolean { + const normalized = normalizeTitle(title) + if (!normalized) return false + if (GENERIC_SERIES_CONTAINERS.has(normalized)) return true + for (const libraryPath of [media.display_library_path, media.library_path]) { + const part = (libraryPath ?? '').split(/[\\/]+/).filter(Boolean).pop() + if (part && normalizeTitle(part) === normalized) return true + } + return false +} + +const SERIES_FILE_EPISODE_RE = /(?:^|[\s._-])(?:s\d{1,2}\s*e\d{1,3}|season\s*\d{1,2}\s*(?:episode|ep)\s*\d{1,3}|\d{1,2}x\d{1,3}|e(?:p(?:isode)?)?\s*\d{1,3})(?:\s|[._-]|$)/i + +function seriesTitleFromFilePath(path?: string): string { + const part = (path ?? '').split(/[\\/]+/).filter(Boolean).pop() ?? '' + if (!part) return '' + const withoutExtension = part.replace(/\.[^.]+$/, '') + const cleaned = cleanSeriesReleaseNoise(normalizeTitle(withoutExtension.replace(SERIES_FILE_EPISODE_RE, ' '))) + return unsafeEpisodeTitle(cleaned) ? '' : cleaned } function normalizeTitle(value?: string): string { @@ -266,19 +296,36 @@ export function groupSeries(items: Media[] = []): SeriesCard[] { const titleKey = repeatedSeriesTitleRawKey(media) if (titleKey) titleCounts.set(titleKey, (titleCounts.get(titleKey) ?? 0) + 1) } + const pathTitleCandidates = new Map>() + for (const media of safeItems) { + if (!media || (!isEpisodeLike(media) && !pathLooksEpisodic(media))) continue + const pathKey = getSeriesRawKey(media) + const titleKey = repeatedSeriesTitleRawKey(media) + if (!pathKey.startsWith('library-path') || !titleKey || (titleCounts.get(titleKey) ?? 0) < 2) continue + const candidates = pathTitleCandidates.get(pathKey) ?? new Set() + candidates.add(titleKey) + pathTitleCandidates.set(pathKey, candidates) + } + const pathTitles = new Map() + for (const [pathKey, candidates] of pathTitleCandidates) { + if (candidates.size === 1) pathTitles.set(pathKey, candidates.values().next().value as string) + } const groups = new Map() for (const m of safeItems) { if (!m) continue const pathKey = getSeriesRawKey(m) const externalKey = repeatedSeriesExternalRawKey(m) const titleKey = repeatedSeriesTitleRawKey(m) - const key = pathKey.startsWith('library-path') && (pathCounts.get(pathKey) ?? 0) > 1 - ? compactSeriesKey(pathKey) - : externalKey && (externalCounts.get(externalKey) ?? 0) > 1 - ? compactSeriesKey(externalKey) - : titleKey && (titleCounts.get(titleKey) ?? 0) > 1 - ? compactSeriesKey(titleKey) - : getSeriesKey(m) + const bridgedTitleKey = pathTitles.get(pathKey) + const key = titleKey && (titleCounts.get(titleKey) ?? 0) > 1 + ? compactSeriesKey(titleKey) + : bridgedTitleKey + ? compactSeriesKey(bridgedTitleKey) + : pathKey.startsWith('library-path') && (pathCounts.get(pathKey) ?? 0) > 1 + ? compactSeriesKey(pathKey) + : externalKey && (externalCounts.get(externalKey) ?? 0) > 1 + ? compactSeriesKey(externalKey) + : getSeriesKey(m) const g = groups.get(key) if (!g) {