mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16: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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user