diff --git a/internal/service/media_series.go b/internal/service/media_series.go index fb71855..b060085 100644 --- a/internal/service/media_series.go +++ b/internal/service/media_series.go @@ -189,19 +189,40 @@ func (s *MediaService) ListMediaEpisodes(ctx context.Context, mediaID string, vi } } - // 如果没有聚合到多集,尝试同父目录匹配 - if len(out) <= 1 && target.Path != "" { - targetDir := filepath.Dir(strings.ReplaceAll(target.Path, "\\", "/")) - dirMatches := make([]model.Media, 0) - for _, row := range rows { - if row.Path != "" && filepath.Dir(strings.ReplaceAll(row.Path, "\\", "/")) == targetDir { - dirMatches = append(dirMatches, row) + // 如果没有聚合到多集,尝试同父目录匹配(排除合集目录和公共分类目录,且同目录文件不能是互不相同的独立电影) + if len(out) <= 1 && target.Path != "" { + targetDir := filepath.Dir(strings.ReplaceAll(target.Path, "\\", "/")) + parentBase := filepath.Base(targetDir) + if !mediaParentLooksLikeCollection(target.Path) && !seriesTitleIsGenericContainer(parentBase, *target) { + targetTitleNorm := normalizeSeriesTitle(target.Title) + targetDirNorm := normalizeSeriesTitle(parentBase) + dirMatches := make([]model.Media, 0) + for _, row := range rows { + if row.Path == "" || filepath.Dir(strings.ReplaceAll(row.Path, "\\", "/")) != targetDir { + continue + } + if row.ID == target.ID { + dirMatches = append(dirMatches, row) + continue + } + rowTitleNorm := normalizeSeriesTitle(row.Title) + allowMatch := false + if isGenericMovieTitle(rowTitleNorm) || isGenericMovieTitle(targetTitleNorm) { + allowMatch = true + } else if rowTitleNorm != "" && rowTitleNorm == targetTitleNorm { + allowMatch = true + } else if rowTitleNorm != "" && targetDirNorm != "" && rowTitleNorm == targetDirNorm { + allowMatch = true + } + if allowMatch { + dirMatches = append(dirMatches, row) + } + } + if len(dirMatches) > 1 { + out = dirMatches + } } } - if len(dirMatches) > 1 { - out = dirMatches - } - } if len(out) == 0 { out = []model.Media{*target} diff --git a/internal/service/media_series_test.go b/internal/service/media_series_test.go index 2ce15fd..412204b 100644 --- a/internal/service/media_series_test.go +++ b/internal/service/media_series_test.go @@ -463,8 +463,49 @@ func TestGroupMediaSeriesCardsKeepsIndependentMoviesSeparateInSharedSubdirectory {LibraryID: "movies", Title: "cd1", Path: `/media/电影/指环王 (2001)/cd1.mkv`}, {LibraryID: "movies", Title: "cd2", Path: `/media/电影/指环王 (2001)/cd2.mkv`}, } - cdCards := groupMediaSeriesCards(cdItems) - if len(cdCards) != 1 { - t.Fatalf("got %d cards for cd1/cd2, want 1 folded movie card", len(cdCards)) + cdCards := groupMediaSeriesCards(cdItems) + if len(cdCards) != 1 { + t.Fatalf("got %d cards for cd1/cd2, want 1 folded movie card", len(cdCards)) + } + } + +func TestListMediaEpisodesKeepsIndependentMoviesSeparate(t *testing.T) { + db := newServiceTestDB(t, &model.Library{}, &model.Media{}) + repos := repository.New(db) + lib := model.Library{Base: model.Base{ID: "lib-movies"}, Name: "电影", Type: "movies", Enabled: true} + if err := repos.DB.Create(&lib).Error; err != nil { + t.Fatal(err) + } + + m1 := model.Media{ + Base: model.Base{ID: "m1"}, + LibraryID: lib.ID, + Title: "老师2024偷窥篇", + Path: `/media/小姐姐/国产/nana/老师2024偷窥篇.strm`, + } + m2 := model.Media{ + Base: model.Base{ID: "m2"}, + LibraryID: lib.ID, + Title: "紫光灯下的肉体诱惑", + Path: `/media/小姐姐/国产/nana/紫光灯下的肉体诱惑.strm`, + } + m3 := model.Media{ + Base: model.Base{ID: "m3"}, + LibraryID: lib.ID, + Title: "修洗衣机", + Path: `/media/小姐姐/国产/nana/修洗衣机.strm`, + } + if err := repos.DB.Create(&[]model.Media{m1, m2, m3}).Error; err != nil { + t.Fatal(err) + } + + svc := NewMediaService(&config.Config{}, zap.NewNop(), repos) + eps, err := svc.ListMediaEpisodes(t.Context(), "m1", MediaVisibility{IncludeNSFW: true}) + if err != nil { + t.Fatal(err) + } + if len(eps) != 1 || eps[0].ID != "m1" { + t.Fatalf("ListMediaEpisodes got %#v, want exactly m1", eps) } } + diff --git a/web/src/components/LayoutHeaderSections.tsx b/web/src/components/LayoutHeaderSections.tsx index 7f69151..a245145 100644 --- a/web/src/components/LayoutHeaderSections.tsx +++ b/web/src/components/LayoutHeaderSections.tsx @@ -6,6 +6,7 @@ import { ArrowLeft, Film, LoaderCircle, Menu, Search, Star, X } from 'lucide-rea import { imageURL } from '../api/client' import { mediaAPI } from '../api/library' import type { Media, PlayProfile, User } from '../types' +import { favouriteMediaLink } from '../utils/mediaNavigation' import { resolveHeaderBack } from './layoutNavigation' import { LayoutThemeToggle } from './LayoutThemeToggle' import { LayoutUserMenu } from './LayoutUserMenu' @@ -149,17 +150,17 @@ function LayoutHeaderSearch() { return () => document.removeEventListener('mousedown', handleClickOutside) }, []) - const handleSelect = (mediaId: string) => { + const handleSelect = (item: Media) => { setIsOpen(false) setQuery('') - navigate(`/media/${mediaId}`) + navigate(favouriteMediaLink(item)) } const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Escape') { setIsOpen(false) } else if (e.key === 'Enter' && results.length > 0) { - handleSelect(results[0].id) + handleSelect(results[0]) } } @@ -220,7 +221,7 @@ function LayoutHeaderSearch() { {results.map((item) => (