From 8fa5db88ffb5d51a6ca73f28850c595f75dc3e52 Mon Sep 17 00:00:00 2001 From: truewhile <62226914+truewhile@users.noreply.github.com> Date: Tue, 25 Aug 2026 22:54:46 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 优化 --- internal/service/emby_compat.go | 1 + .../service/emby_series_hierarchy_test.go | 102 ++++++++++++++++++ internal/service/emby_series_ids.go | 31 +++++- 3 files changed, 133 insertions(+), 1 deletion(-) diff --git a/internal/service/emby_compat.go b/internal/service/emby_compat.go index bc7d0c2..b46652f 100644 --- a/internal/service/emby_compat.go +++ b/internal/service/emby_compat.go @@ -111,6 +111,7 @@ const ( var ( embySeasonDirRE = regexp.MustCompile(`(?i)^(season[\s._-]*\d+|s\d+|specials?|sp|ova|oad|extra|extras|第\s*[0-9一二三四五六七八九十百零两]+\s*季|特别篇|特別篇|番外|特典)$`) + embySeasonSuffixRE = regexp.MustCompile(`(?i)(?:[\s._-]+(?:season[\s._-]*\d+|s\d+|第\s*[0-9一二三四五六七八九十百零两]+\s*季|specials?|sp|ova|oad|extra|extras|特别篇|特別篇|番外|特典)|\s*第\s*[0-9一二三四五六七八九十百零两]+\s*季)\s*$`) embyYearSuffixRE = regexp.MustCompile(`\s*[\((\[]\d{4}[\))\]]\s*$`) embyEpisodeTitleRE = regexp.MustCompile(`(?i)\s*[-_ ]*s\d{1,2}e\d{1,3}.*$`) ) diff --git a/internal/service/emby_series_hierarchy_test.go b/internal/service/emby_series_hierarchy_test.go index 1fd5877..4ff9a68 100644 --- a/internal/service/emby_series_hierarchy_test.go +++ b/internal/service/emby_series_hierarchy_test.go @@ -334,3 +334,105 @@ func TestEmbyCloudAnimeUsesSeriesNameFromChineseSeasonFolder(t *testing.T) { t.Fatalf("cloud anime should be grouped as one series named 剑来, got %#v", items) } } + +func TestEmbySeriesGroupingWithPrefixedSeasonFolders(t *testing.T) { + svc := newTestEmbyService(t) + lib := model.Library{Name: "动漫", Path: `/media/动漫`, Type: "anime", Enabled: true} + if err := svc.repo.Library.Create(t.Context(), &lib); err != nil { + t.Fatalf("create library: %v", err) + } + + for season := 1; season <= 5; season++ { + for ep := 1; ep <= 3; ep++ { + media := model.Media{ + Base: model.Base{ID: fmt.Sprintf("shokugeki-s%02de%02d", season, ep)}, + LibraryID: lib.ID, + Title: "食戟之灵", + OriginalName: "食戟のソーマ", + ScrapeStatus: "matched", + TMDbID: 62273, + BangumiID: 116461, + Path: fmt.Sprintf(`/media/动漫/食戟之灵/食戟之灵 S%02d/食戟之灵 S%02dE%02d.strm`, season, season, ep), + SeasonNum: season, + EpisodeNum: ep, + } + if err := svc.repo.DB.Create(&media).Error; err != nil { + t.Fatalf("create media: %v", err) + } + } + } + + root, err := svc.Items(t.Context(), ItemsParams{ParentID: lib.ID, Limit: 50}) + if err != nil { + t.Fatalf("library items: %v", err) + } + rootItems := root["Items"].([]map[string]any) + if len(rootItems) != 1 { + t.Fatalf("expected 1 series card for 食戟之灵 across 5 seasons, got %d cards: %#v", len(rootItems), rootItems) + } + if rootItems[0]["Name"] != "食戟之灵" || rootItems[0]["Type"] != "Series" { + t.Fatalf("unexpected series item: %#v", rootItems[0]) + } + seriesID := rootItems[0]["Id"].(string) + + seasons, err := svc.Items(t.Context(), ItemsParams{ParentID: seriesID, Limit: 50}) + if err != nil { + t.Fatalf("series seasons: %v", err) + } + seasonItems := seasons["Items"].([]map[string]any) + if len(seasonItems) != 5 { + t.Fatalf("expected 5 seasons, got %d: %#v", len(seasonItems), seasonItems) + } + for i, s := range seasonItems { + wantSeasonNum := i + 1 + if s["Type"] != "Season" || s["IndexNumber"] != wantSeasonNum { + t.Errorf("season [%d] = %#v, want IndexNumber=%d", i, s, wantSeasonNum) + } + } + + counts, err := svc.ItemCounts(t.Context(), "user-1") + if err != nil { + t.Fatalf("item counts: %v", err) + } + if counts["SeriesCount"] != 1 || counts["EpisodeCount"] != int64(15) { + t.Fatalf("counts = %#v, want 1 series and 15 episodes", counts) + } +} + +func TestInferSeriesNameFromPath(t *testing.T) { + tests := []struct { + path string + want string + }{ + { + path: `/media/动漫/食戟之灵/食戟之灵 S01/食戟之灵 S01E01.strm`, + want: "食戟之灵", + }, + { + path: `/media/动漫/食戟之灵/食戟之灵 S05/食戟之灵 S05E12.strm`, + want: "食戟之灵", + }, + { + path: `/media/动漫/食戟之灵/Season 02/01.mkv`, + want: "食戟之灵", + }, + { + path: `/media/动漫/进击的巨人 第2季/01.mkv`, + want: "进击的巨人", + }, + { + path: `cloud://openlist/国漫/剑来/第二季/04.mkv`, + want: "剑来", + }, + { + path: `/media/tv/间谍过家家 (2022)/Specials/S00E01.mkv`, + want: "间谍过家家", + }, + } + for _, tc := range tests { + got := inferSeriesNameFromPath(tc.path) + if got != tc.want { + t.Errorf("inferSeriesNameFromPath(%q) = %q, want %q", tc.path, got, tc.want) + } + } +} diff --git a/internal/service/emby_series_ids.go b/internal/service/emby_series_ids.go index 914953f..4c59e95 100644 --- a/internal/service/emby_series_ids.go +++ b/internal/service/emby_series_ids.go @@ -31,6 +31,14 @@ func (e *EmbyService) seriesNameForMedia(m *model.Media) string { return series.Title } } + if strings.EqualFold(strings.TrimSpace(m.ScrapeStatus), "matched") && strings.TrimSpace(m.Title) != "" { + name := strings.TrimSpace(m.Title) + name = embyEpisodeTitleRE.ReplaceAllString(name, "") + name = embyYearSuffixRE.ReplaceAllString(name, "") + if name != "" { + return name + } + } if name := inferSeriesNameFromPath(m.Path); name != "" { return name } @@ -53,14 +61,35 @@ func inferSeriesNameFromPath(path string) string { if embySeasonDirRE.MatchString(base) { dir = filepath.Dir(dir) base = filepath.Base(dir) + } else if stripped := strings.TrimSpace(embySeasonSuffixRE.ReplaceAllString(base, "")); stripped != "" && stripped != base { + parentDir := filepath.Dir(dir) + parentBase := filepath.Base(parentDir) + if parentBase != "." && parentBase != string(filepath.Separator) && !isEmbyGenericContainer(parentBase) { + dir = parentDir + base = parentBase + } else { + base = stripped + } } base = strings.TrimSpace(embyYearSuffixRE.ReplaceAllString(base, "")) - if base == "." || base == string(filepath.Separator) { + if base == "." || base == string(filepath.Separator) || isEmbyGenericContainer(base) { return "" } return base } +func isEmbyGenericContainer(name string) bool { + switch strings.ToLower(strings.TrimSpace(name)) { + case "movie", "movies", "film", "films", "tv", "series", "show", "shows", "anime", "animation", "variety", + "电视剧", "剧集", "连续剧", "短剧", "国产剧", "国剧", "欧美剧", "美剧", "英剧", "日韩剧", "日剧", "韩剧", "港剧", "台剧", "港台剧", + "综艺", "纪录片", "儿童", "动漫", "番剧", "国漫", "日番", "韩漫", "美漫", "欧美动漫", "欧美动画", "其他动漫", "电影", "成人", "未分类", + "media", "downloads", "download", "videos", "video", "share", "shares": + return true + default: + return false + } +} + func stableEmbyID(prefix string, parts ...string) string { h := sha256.New() for _, part := range parts {