From a02583ee7d47c9fe233893b173aac59814025e7e Mon Sep 17 00:00:00 2001 From: truewhile <62226914+truewhile@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:00:01 +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_remote_web.go | 21 ++++-- internal/service/media_versions.go | 26 ++++++++ internal/service/media_versions_test.go | 46 +++++++++++++ web/src/components/LayoutHeaderSections.tsx | 13 +++- web/src/pages/MediaDetailPage.tsx | 17 +++++ web/src/pages/MediaDetailPageSections.tsx | 17 +++-- web/src/pages/useMediaDetailPageState.ts | 74 ++++++++++++++++++++- 7 files changed, 199 insertions(+), 15 deletions(-) diff --git a/internal/service/emby_remote_web.go b/internal/service/emby_remote_web.go index d1334da..27f1584 100644 --- a/internal/service/emby_remote_web.go +++ b/internal/service/emby_remote_web.go @@ -301,11 +301,22 @@ func (r *EmbyRemoteService) MapRemoteItemToMedia(ctx context.Context, mount *mod media.SeasonNum = 0 media.EpisodeNum = 0 } - if mount != nil && strings.TrimSpace(mount.RemoteViewID) != "" { - libID := EncodeEmbyRemoteID(mount.ID, mount.RemoteViewID) - media.DisplayLibraryID = libID - media.LibraryID = libID - } + if mount != nil && strings.TrimSpace(mount.RemoteViewID) != "" { + libID := EncodeEmbyRemoteID(mount.ID, mount.RemoteViewID) + media.DisplayLibraryID = libID + media.LibraryID = libID + libName := strings.TrimSpace(mount.Name) + if libName == "" { + libName = strings.TrimSpace(mount.RemoteViewName) + } + if libName == "" && acct != nil { + libName = acct.Name + } else if acct != nil && acct.Name != "" && !strings.Contains(libName, acct.Name) { + libName = acct.Name + " · " + libName + } + media.LibraryName = libName + media.DisplayLibraryName = libName + } return media } diff --git a/internal/service/media_versions.go b/internal/service/media_versions.go index 92997ac..aea4764 100644 --- a/internal/service/media_versions.go +++ b/internal/service/media_versions.go @@ -120,6 +120,11 @@ func GroupMediaVersions(items []model.Media) []MediaItem { } func mediaVersionGroupKey(m model.Media) string { + // 远程 Emby 挂载条目保持独立,不与其它远程条目或本地条目折叠合并。 + if IsEmbyRemoteID(m.ID) { + return fmt.Sprintf("embyremote:%s", m.ID) + } + if m.SeasonNum > 0 || m.EpisodeNum > 0 { switch { case m.TMDbID > 0: @@ -146,14 +151,32 @@ func mediaVersionGroupKey(m model.Media) string { fmt.Sprintf("%d:%d", m.SeasonNum, m.EpisodeNum), }, "|") } + + libKey := strings.ToLower(strings.TrimSpace(m.LibraryID)) + if libKey == "" { + libKey = strings.ToLower(strings.TrimSpace(m.DisplayLibraryID)) + } + switch { case m.TMDbID > 0: + if libKey != "" { + return fmt.Sprintf("movie:%s:tmdb:%d", libKey, m.TMDbID) + } return fmt.Sprintf("tmdb:%d", m.TMDbID) case m.BangumiID > 0: + if libKey != "" { + return fmt.Sprintf("movie:%s:bangumi:%d", libKey, m.BangumiID) + } return fmt.Sprintf("bangumi:%d", m.BangumiID) case strings.TrimSpace(m.DoubanID) != "": + if libKey != "" { + return fmt.Sprintf("movie:%s:douban:%s", libKey, strings.ToLower(strings.TrimSpace(m.DoubanID))) + } return "douban:" + strings.ToLower(strings.TrimSpace(m.DoubanID)) case strings.TrimSpace(m.TheTVDBID) != "": + if libKey != "" { + return fmt.Sprintf("movie:%s:thetvdb:%s", libKey, strings.ToLower(strings.TrimSpace(m.TheTVDBID))) + } return "thetvdb:" + strings.ToLower(strings.TrimSpace(m.TheTVDBID)) } title := firstNonEmpty(m.OriginalName, m.Title) @@ -173,6 +196,9 @@ func mediaVersionGroupKey(m model.Media) string { if year <= 0 { _, year = CleanQuery(m.Path) } + if libKey != "" { + return fmt.Sprintf("movie:%s:%s:%d", libKey, title, year) + } return fmt.Sprintf("movie:%s:%d", title, year) } diff --git a/internal/service/media_versions_test.go b/internal/service/media_versions_test.go index d5bc044..f93175d 100644 --- a/internal/service/media_versions_test.go +++ b/internal/service/media_versions_test.go @@ -218,3 +218,49 @@ func TestSearchMediaVisiblePageGroupedPaginatesAfterVersionGrouping(t *testing.T t.Fatalf("primary version = %q, want %q", page[0].Media.Path, rows[0].Path) } } + +func TestGroupMediaVersionsDoesNotMergeDifferentRemoteSeries(t *testing.T) { + // 模拟来自两个不同挂载库(如 00 新番连载 与 2018 动漫)的同名剧集《碧蓝之海》 + season1 := model.Media{ + Base: model.Base{ID: EncodeEmbyRemoteID("mount-1", "156019")}, + LibraryID: EncodeEmbyRemoteID("mount-1", "view-1"), + Title: "碧蓝之海", + Year: 2018, + LibraryName: "2018 动漫", + } + season2 := model.Media{ + Base: model.Base{ID: EncodeEmbyRemoteID("mount-2", "156030")}, + LibraryID: EncodeEmbyRemoteID("mount-2", "view-2"), + Title: "碧蓝之海", + Year: 2024, + LibraryName: "00 新番连载", + } + + grouped := groupMediaVersions([]model.Media{season1, season2}) + if len(grouped) != 2 { + t.Fatalf("expected 2 separate groups for different remote series/libraries, got %d: %#v", len(grouped), grouped) + } + if grouped[0].Title != "碧蓝之海" || grouped[1].Title != "碧蓝之海" { + t.Fatalf("expected both titles to be '碧蓝之海'") + } +} + +func TestGroupMediaVersionsDoesNotMergeAcrossDifferentLibrariesForMovies(t *testing.T) { + movieLib1 := model.Media{ + Base: model.Base{ID: "m-1"}, + LibraryID: "lib-1", + Title: "碧蓝之海", + Year: 2018, + } + movieLib2 := model.Media{ + Base: model.Base{ID: "m-2"}, + LibraryID: "lib-2", + Title: "碧蓝之海", + Year: 2018, + } + + grouped := groupMediaVersions([]model.Media{movieLib1, movieLib2}) + if len(grouped) != 2 { + t.Fatalf("expected 2 separate groups for movies in different libraries, got %d", len(grouped)) + } +} diff --git a/web/src/components/LayoutHeaderSections.tsx b/web/src/components/LayoutHeaderSections.tsx index e900e24..7f69151 100644 --- a/web/src/components/LayoutHeaderSections.tsx +++ b/web/src/components/LayoutHeaderSections.tsx @@ -239,9 +239,16 @@ function LayoutHeaderSearch() {
-

- {item.title} -

+
+

+ {item.title} +

+ {(item.display_library_name || item.library_name) && ( + + {item.display_library_name || item.library_name} + + )} +
{item.year > 0 && {item.year}} {item.rating > 0 && ( diff --git a/web/src/pages/MediaDetailPage.tsx b/web/src/pages/MediaDetailPage.tsx index 13bc28f..326d8c0 100644 --- a/web/src/pages/MediaDetailPage.tsx +++ b/web/src/pages/MediaDetailPage.tsx @@ -9,6 +9,7 @@ import { MediaDetailMainContent, MediaDetailMissing, } from './MediaDetailPageSections' +import { LibrarySeriesEpisodes } from './LibrarySeriesEpisodes' import { useMediaDetailPageState } from './useMediaDetailPageState' export function MediaDetailPage() { @@ -31,6 +32,7 @@ export function MediaDetailPage() { media={media} isAdmin={role === 'admin'} favourite={detail.favourite} + playTargetId={detail.firstPlayableEpisode?.id} scrapeEpisodeArtwork={detail.scrapeEpisodeArtwork} onToggleFavourite={detail.toggleFavourite} onScrapeEpisodeArtworkChange={detail.setScrapeEpisodeArtwork} @@ -42,6 +44,21 @@ export function MediaDetailPage() { onExportNFO={detail.exportNFO} onSoftDelete={detail.softDelete} /> + + {detail.hasEpisodes && ( +
+
+ +
+ )} + void + playTargetId?: string } interface MediaDetailMainContentProps extends MediaDetailPlaybackActionsProps { @@ -93,17 +94,19 @@ export function MediaDetailPlaybackActions({ media, favourite, onToggleFavourite, + playTargetId, }: MediaDetailPlaybackActionsProps) { + const targetId = playTargetId || media.id return (
- + 立即播放 {!isDirectStreamMedia(media) && ( @@ -112,7 +115,7 @@ export function MediaDetailPlaybackActions({ )} - +