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)
}
}
+57 -10
View File
@@ -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<string, Set<string>>()
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<string>()
candidates.add(titleKey)
pathTitleCandidates.set(pathKey, candidates)
}
const pathTitles = new Map<string, string>()
for (const [pathKey, candidates] of pathTitleCandidates) {
if (candidates.size === 1) pathTitles.set(pathKey, candidates.values().next().value as string)
}
const groups = new Map<string, SeriesCard>()
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) {