diff --git a/internal/handler/emby_images.go b/internal/handler/emby_images.go index 1dc9ae3..bade726 100644 --- a/internal/handler/emby_images.go +++ b/internal/handler/emby_images.go @@ -29,7 +29,7 @@ var embyPlaceholderPNG = []byte{ // /api/img 会变成 401,所以这里复用 ImageProxy 但不再走 /api 路由。 func embyItemImageHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { - embyServeImage(c, svc, c.Param("id"), c.Param("type"), false) + embyServeImage(c, svc, c.Param("id"), c.Param("type"), c.Query("tag"), false) } } @@ -37,11 +37,11 @@ func embyItemImageHandler(svc *service.Container) gin.HandlerFunc { // Name 可能是伪装后的远程人物 ID,也可能是电影详情 People 中的显示名称。 func embyPersonImageHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { - embyServeImage(c, svc, c.Param("name"), c.Param("type"), true) + embyServeImage(c, svc, c.Param("name"), c.Param("type"), c.Query("tag"), true) } } -func embyServeImage(c *gin.Context, svc *service.Container, id, imageType string, person bool) { +func embyServeImage(c *gin.Context, svc *service.Container, id, imageType, tag string, person bool) { clearEmbyImageNoStoreHeaders(c) ctx, cancel := context.WithTimeout(c.Request.Context(), 8*time.Second) defer cancel() @@ -53,7 +53,7 @@ func embyServeImage(c *gin.Context, svc *service.Container, id, imageType string var raw string var err error if person { - raw, err = svc.Emby.PersonImageURL(ctx, id, imageType) + raw, err = svc.Emby.PersonImageURL(ctx, id, imageType, tag) } else { raw, err = svc.Emby.ImageURL(ctx, id, imageType) } diff --git a/internal/service/adult_people.go b/internal/service/adult_people.go new file mode 100644 index 0000000..d68cb15 --- /dev/null +++ b/internal/service/adult_people.go @@ -0,0 +1,27 @@ +package service + +import ( + "context" + "strings" + + "github.com/truewhile/MeBox/internal/model" +) + +// GetPeople resolves adult cast/crew on demand from MetaTube. The provider and +// remote movie ID are persisted in the existing external-ID columns; the +// returned people are intentionally not written to the database. +func (p *AdultProvider) GetPeople(ctx context.Context, media *model.Media) []map[string]any { + if p == nil || media == nil || !media.NSFW { + return nil + } + provider := strings.TrimSpace(media.TheTVDBID) + movieID := strings.TrimSpace(media.DoubanID) + if provider == "" || movieID == "" { + return nil + } + match, err := p.GetMetaTubeCandidate(ctx, provider, movieID) + if err != nil || match == nil { + return nil + } + return match.People +} diff --git a/internal/service/emby_artwork.go b/internal/service/emby_artwork.go index 7dfbf1f..f5efa64 100644 --- a/internal/service/emby_artwork.go +++ b/internal/service/emby_artwork.go @@ -85,7 +85,7 @@ func (e *EmbyService) resolveVirtualArtwork(ctx context.Context, id, imageType s // PersonImageURL resolves a person avatar from either a disguised remote ID // or a person display name captured from a remote item's People field. -func (e *EmbyService) PersonImageURL(ctx context.Context, idOrName, imageType string) (string, error) { +func (e *EmbyService) PersonImageURL(ctx context.Context, idOrName, imageType, tag string) (string, error) { idOrName = strings.TrimSpace(idOrName) if idOrName == "" || e == nil { return "", nil @@ -93,6 +93,26 @@ func (e *EmbyService) PersonImageURL(ctx context.Context, idOrName, imageType st if IsEmbyRemoteID(idOrName) { return e.ImageURL(ctx, idOrName, imageType) } + if personID, ok := parseTMDbPersonID(idOrName); ok && e.tmdb != nil { + profilePath := tmdbProfilePathFromTag(tag) + if profilePath == "" { + profilePath = e.cachedPersonImage(idOrName) + } + if profilePath != "" { + if raw := e.tmdb.ProfileImageURL(profilePath); raw != "" { + e.rememberPersonImage(idOrName, profilePath) + return raw, nil + } + } + profilePath, err := e.tmdb.PersonProfilePathByID(ctx, personID) + if err != nil { + return "", err + } + if raw := e.tmdb.ProfileImageURL(profilePath); raw != "" { + e.rememberPersonImage(idOrName, profilePath) + return raw, nil + } + } if e.remote != nil { if raw, ok := e.remote.ResolveRemotePersonImageURL(ctx, idOrName, imageType); ok { return raw, nil diff --git a/internal/service/emby_compat.go b/internal/service/emby_compat.go index 72b2bea..61dc956 100644 --- a/internal/service/emby_compat.go +++ b/internal/service/emby_compat.go @@ -64,6 +64,11 @@ type EmbyService struct { peopleMu sync.RWMutex peopleCache map[string]embyPeopleCacheEntry + + tmdb *TMDbProvider + adult *AdultProvider + personImageMu sync.RWMutex + personImages map[string]string } // NewEmbyService is the constructor. @@ -79,6 +84,23 @@ func (e *EmbyService) SetEmbyRemote(remote *EmbyRemoteService) *EmbyService { return e } +// SetTMDbProvider wires the TMDb client used for detail-time cast/crew lookup. +func (e *EmbyService) SetTMDbProvider(tmdb *TMDbProvider) *EmbyService { + if e != nil { + e.tmdb = tmdb + } + return e +} + +// SetAdultProvider wires the on-demand adult-metadata provider used when a +// detail request needs cast/crew not stored in the database. +func (e *EmbyService) SetAdultProvider(adult *AdultProvider) *EmbyService { + if e != nil { + e.adult = adult + } + return e +} + func (e *EmbyService) SetRuntimeCache(cache *RuntimeCacheService) *EmbyService { if e != nil { e.cache = cache @@ -118,6 +140,8 @@ const ( embyVirtualSeriesPrefix = "msgo-series-" embyVirtualSeasonPrefix = "msgo-season-" embyVirtualCacheTTL = 10 * time.Minute + embyPeopleCacheTTL = 6 * time.Hour + embyPeopleEmptyCacheTTL = 15 * time.Minute embyVisibilityCacheTTL = 30 * time.Second embySeriesGroupingLimit = maxMediaSearchLimit // Virtual artwork used to be wiped entirely once the in-memory map crossed diff --git a/internal/service/emby_items_detail.go b/internal/service/emby_items_detail.go index 3c7a492..5567e2d 100644 --- a/internal/service/emby_items_detail.go +++ b/internal/service/emby_items_detail.go @@ -10,6 +10,8 @@ import ( "strings" "time" + "go.uber.org/zap" + "github.com/truewhile/MeBox/internal/model" ) @@ -62,14 +64,22 @@ func (e *EmbyService) Item(ctx context.Context, mediaID, userID string) (map[str if season, ok, err := e.findSeasonGroup(ctx, mediaID, userID); err != nil { return nil, err } else if ok { - return e.seasonPayload(season), nil + item := e.seasonPayload(season) + if media := seriesPeopleMedia(season.Series); media != nil { + item["People"] = e.resolveMediaPeople(ctx, media) + } + return item, nil } } if strings.HasPrefix(mediaID, embyVirtualSeriesPrefix) { if series, ok, err := e.findSeriesGroup(ctx, mediaID, userID); err != nil { return nil, err } else if ok { - return e.seriesPayload(series), nil + item := e.seriesPayload(series) + if media := seriesPeopleMedia(series); media != nil { + item["People"] = e.resolveMediaPeople(ctx, media) + } + return item, nil } } m, err := e.repo.Media.FindByID(ctx, mediaID) @@ -80,7 +90,11 @@ func (e *EmbyService) Item(ctx context.Context, mediaID, userID string) (map[str if series, ok, err := e.findSeriesGroup(ctx, mediaID, userID); err != nil { return nil, err } else if ok { - return e.seriesPayload(series), nil + item := e.seriesPayload(series) + if media := seriesPeopleMedia(series); media != nil { + item["People"] = e.resolveMediaPeople(ctx, media) + } + return item, nil } return nil, nil } @@ -103,7 +117,7 @@ func (e *EmbyService) Item(ctx context.Context, mediaID, userID string) (map[str } } // 单条目 payload 内部对库类型/series 标题有多次查找,挂请求级缓存合并。 - return e.itemPayload(e.withPayloadCache(ctx), m, fav, pos), nil + return e.itemPayload(e.withPayloadCache(ctx), m, fav, pos, true), nil } // LatestItems 最近添加,全库或指定库。远程媒体库(parentID 带前缀)直接透传远程。 @@ -256,7 +270,7 @@ func (e *EmbyService) favoriteItems(ctx context.Context, p ItemsParams) (map[str continue } } - items = append(items, e.itemPayload(ctx, m, true, 0)) + items = append(items, e.itemPayload(ctx, m, true, 0, false)) continue } if e.remote == nil || !IsEmbyRemoteID(fav.MediaID) { @@ -388,7 +402,7 @@ func (e *EmbyService) resumableItems(ctx context.Context, p ItemsParams) (map[st } localTotal++ if produced := len(items); produced < needed { - items = append(items, e.itemPayload(ctx, m, false, h.PositionMs)) + items = append(items, e.itemPayload(ctx, m, false, h.PositionMs, false)) } continue } @@ -427,7 +441,7 @@ func (e *EmbyService) resumableItems(ctx context.Context, p ItemsParams) (map[st return map[string]any{"Items": items[p.StartIndex:end], "TotalRecordCount": total, "StartIndex": p.StartIndex}, nil } -func (e *EmbyService) itemPayload(ctx context.Context, m *model.Media, fav bool, posMs int64) map[string]any { +func (e *EmbyService) itemPayload(ctx context.Context, m *model.Media, fav bool, posMs int64, includePeople bool) map[string]any { itemType := "Movie" name := m.Title parentID := m.LibraryID @@ -500,7 +514,7 @@ func (e *EmbyService) itemPayload(ctx context.Context, m *model.Media, fav bool, "ImageTags": imageTags, "BackdropImageTags": backdropTags, "Genres": splitCSV(m.Genres), - "People": e.resolveMediaPeople(ctx, m), + "People": []map[string]any{}, "ProviderIds": map[string]string{ "Tmdb": intToStr(m.TMDbID), "Bangumi": intToStr(m.BangumiID), @@ -531,21 +545,93 @@ func (e *EmbyService) itemPayload(ctx context.Context, m *model.Media, fav bool, if premiered, ok := embyPremiereDate(m.ReleaseDate); ok { item["PremiereDate"] = premiered } + if includePeople { + item["People"] = e.resolveMediaPeople(ctx, m) + } return item } func (e *EmbyService) resolveMediaPeople(ctx context.Context, m *model.Media) []map[string]any { - if m == nil || strings.TrimSpace(m.Path) == "" { + if m == nil { return []map[string]any{} } - cacheKey := strings.TrimSpace(m.ID) + cacheKey := embyMediaPeopleCacheKey(m) if cacheKey == "" { - cacheKey = strings.ToLower(filepath.Clean(m.Path)) + return []map[string]any{} } if people, ok := e.cachedMediaPeople(cacheKey); ok { return people } + people := e.fetchTMDbPeople(ctx, m) + if len(people) == 0 { + people = e.resolveNFOMediaPeople(m) + } + e.rememberMediaPeople(cacheKey, people) + return people +} +func seriesPeopleMedia(group embySeriesGroup) *model.Media { + if group.TMDbID <= 0 { + return nil + } + if len(group.Episodes) > 0 { + media := group.Episodes[0] + media.TMDbID = group.TMDbID + return &media + } + return &model.Media{Title: group.Name, TMDbID: group.TMDbID, SeasonNum: 1} +} + +func (e *EmbyService) fetchTMDbPeople(ctx context.Context, m *model.Media) []map[string]any { + if e == nil || m == nil { + return nil + } + var people []map[string]any + if e.tmdb != nil && m.TMDbID > 0 { + mediaType := "movie" + if m.SeasonNum > 0 || m.EpisodeNum > 0 { + mediaType = "tv" + } + people, err := e.tmdb.GetPeople(ctx, m.TMDbID, mediaType) + if err != nil { + if e.log != nil { + e.log.Debug("tmdb: detail people lookup failed", zap.Int("tmdb_id", m.TMDbID), zap.String("type", mediaType), zap.Error(err)) + } + } else if len(people) > 0 { + e.rememberPersonImages(people) + return people + } + } + if m.NSFW && e.adult != nil { + people = e.adult.GetPeople(ctx, m) + } + return people +} + +func embyMediaPeopleCacheKey(m *model.Media) string { + if m == nil { + return "" + } + if m.TMDbID > 0 { + mediaType := "movie" + if m.SeasonNum > 0 || m.EpisodeNum > 0 { + mediaType = "tv" + } + return fmt.Sprintf("tmdb:%d:%s", m.TMDbID, mediaType) + } + if strings.TrimSpace(m.ID) != "" { + return "media:" + strings.TrimSpace(m.ID) + } + if strings.TrimSpace(m.Path) != "" { + return "path:" + strings.ToLower(filepath.Clean(m.Path)) + } + return "" +} + +func (e *EmbyService) resolveNFOMediaPeople(m *model.Media) []map[string]any { + if m == nil || strings.TrimSpace(m.Path) == "" { + return []map[string]any{} + } dir := filepath.Dir(m.Path) candidates := make([]string, 0, 6) seenPath := map[string]struct{}{} @@ -571,7 +657,6 @@ func (e *EmbyService) resolveMediaPeople(ctx context.Context, m *model.Media) [] people := make([]map[string]any, 0) seen := make(map[string]bool) - for _, p := range candidates { if fi, err := os.Stat(p); err == nil && !fi.IsDir() { doc, _, err := decodeNFOFile(p) @@ -618,7 +703,6 @@ func (e *EmbyService) resolveMediaPeople(ctx context.Context, m *model.Media) [] } } } - e.rememberMediaPeople(cacheKey, people) return people } @@ -654,12 +738,57 @@ func (e *EmbyService) rememberMediaPeople(key string, people []map[string]any) { } stored := make([]map[string]any, len(people)) copy(stored, people) + ttl := embyPeopleCacheTTL + if len(stored) == 0 { + ttl = embyPeopleEmptyCacheTTL + } e.peopleCache[key] = embyPeopleCacheEntry{ people: stored, - expiresAt: time.Now().Add(embyVirtualCacheTTL), + expiresAt: time.Now().Add(ttl), } } +func (e *EmbyService) rememberPersonImages(people []map[string]any) { + if e == nil || len(people) == 0 { + return + } + e.personImageMu.Lock() + defer e.personImageMu.Unlock() + if e.personImages == nil || len(e.personImages) > 20000 { + e.personImages = make(map[string]string, 128) + } + for _, person := range people { + id := strings.TrimSpace(fmt.Sprint(person["Id"])) + profilePath := tmdbProfilePathFromTag(fmt.Sprint(person["PrimaryImageTag"])) + if _, ok := parseTMDbPersonID(id); ok && profilePath != "" { + e.personImages[id] = profilePath + } + } +} + +func (e *EmbyService) cachedPersonImage(id string) string { + if e == nil { + return "" + } + e.personImageMu.RLock() + profilePath := e.personImages[strings.TrimSpace(id)] + e.personImageMu.RUnlock() + return profilePath +} + +func (e *EmbyService) rememberPersonImage(id, profilePath string) { + profilePath = strings.TrimSpace(profilePath) + if e == nil || strings.TrimSpace(id) == "" || !tmdbProfilePathRE.MatchString(profilePath) { + return + } + e.personImageMu.Lock() + if e.personImages == nil { + e.personImages = make(map[string]string, 128) + } + e.personImages[strings.TrimSpace(id)] = profilePath + e.personImageMu.Unlock() +} + func embyPersonID(name, roleType string) string { sum := sha256.Sum256([]byte(strings.ToLower(strings.TrimSpace(name)) + ":" + strings.ToLower(strings.TrimSpace(roleType)))) return "person-" + hex.EncodeToString(sum[:8]) diff --git a/internal/service/emby_items_list.go b/internal/service/emby_items_list.go index 174176d..0d04187 100644 --- a/internal/service/emby_items_list.go +++ b/internal/service/emby_items_list.go @@ -181,7 +181,7 @@ func (e *EmbyService) payloadsForMedia(ctx context.Context, rows []model.Media, items := make([]map[string]any, 0, len(rows)) for _, m := range rows { - items = append(items, e.itemPayload(ctx, &m, userFavs[m.ID], userPos[m.ID])) + items = append(items, e.itemPayload(ctx, &m, userFavs[m.ID], userPos[m.ID], false)) } return items, nil } diff --git a/internal/service/emby_people_test.go b/internal/service/emby_people_test.go index 28ebcea..9698e01 100644 --- a/internal/service/emby_people_test.go +++ b/internal/service/emby_people_test.go @@ -1,10 +1,17 @@ package service import ( + "encoding/json" + "net/http" + "net/http/httptest" "os" "path/filepath" + "sync/atomic" "testing" + "go.uber.org/zap" + + "github.com/truewhile/MeBox/internal/config" "github.com/truewhile/MeBox/internal/model" ) @@ -55,3 +62,48 @@ func TestResolveMediaPeopleStillReadsLegacyKeepExtNFO(t *testing.T) { t.Fatalf("expected legacy keep_ext nfo people, got %#v", people) } } + +func TestResolveMediaPeopleFetchesTMDbAndCaches(t *testing.T) { + var hits atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/movie/11/credits" { + http.NotFound(w, r) + return + } + hits.Add(1) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "cast": []map[string]any{ + {"id": 101, "name": "演员甲", "character": "主角", "profile_path": "/actor.jpg", "order": 0}, + }, + }) + })) + defer server.Close() + + svc := newTestEmbyService(t) + svc.SetTMDbProvider(NewTMDbProvider(&config.Config{Secrets: config.SecretsConfig{ + TMDbAPIKey: "test-key", + TMDbAPIProxy: server.URL, + TMDbImageProxy: "https://image.example/t/p", + }}, zap.NewNop(), nil)) + media := &model.Media{Base: model.Base{ID: "movie-people-1"}, Title: "测试电影", Path: "/media/movies/test.mkv", TMDbID: 11} + + people := svc.resolveMediaPeople(t.Context(), media) + peopleAgain := svc.resolveMediaPeople(t.Context(), media) + if len(people) != 1 || len(peopleAgain) != 1 { + t.Fatalf("people=%#v again=%#v", people, peopleAgain) + } + if hits.Load() != 1 { + t.Fatalf("TMDb credits hits=%d want 1", hits.Load()) + } + if people[0]["Id"] != "person~tmdb~101" || people[0]["PrimaryImageTag"] != "tmdb:/actor.jpg" { + t.Fatalf("unexpected person: %#v", people[0]) + } + raw, err := svc.PersonImageURL(t.Context(), "person~tmdb~101", "Primary", "tmdb:/actor.jpg") + if err != nil { + t.Fatal(err) + } + if raw != "https://image.example/t/p/w300/actor.jpg" { + t.Fatalf("person image URL=%q", raw) + } +} diff --git a/internal/service/emby_series_hierarchy_test.go b/internal/service/emby_series_hierarchy_test.go index 431870a..801f668 100644 --- a/internal/service/emby_series_hierarchy_test.go +++ b/internal/service/emby_series_hierarchy_test.go @@ -229,7 +229,7 @@ func TestEmbyEpisodeStillIsPrimaryImageNotArt(t *testing.T) { t.Fatalf("create media: %v", err) } - item := svc.itemPayload(t.Context(), &media, false, 0) + item := svc.itemPayload(t.Context(), &media, false, 0, false) if tags, ok := item["ImageTags"].(map[string]string); !ok || tags["Primary"] != "ep-still" { t.Fatalf("episode should expose a primary image tag: %#v", item["ImageTags"]) } diff --git a/internal/service/local_metadata_adult_test.go b/internal/service/local_metadata_adult_test.go index 1cf5f5f..cfd9a3b 100644 --- a/internal/service/local_metadata_adult_test.go +++ b/internal/service/local_metadata_adult_test.go @@ -55,7 +55,7 @@ func TestReadAdultLocalMetadataAndArtwork(t *testing.T) { if got.PosterURL != poster || got.BackdropURL != fanart { t.Fatalf("artwork poster=%q fanart=%q", got.PosterURL, got.BackdropURL) } - if got.Genres != "剧情,中文字幕,测试片商,演员A" { + if got.Genres != "剧情,中文字幕,测试片商" { t.Fatalf("genres = %q", got.Genres) } } diff --git a/internal/service/local_metadata_convert.go b/internal/service/local_metadata_convert.go index b519b1d..1394197 100644 --- a/internal/service/local_metadata_convert.go +++ b/internal/service/local_metadata_convert.go @@ -61,7 +61,7 @@ func adultAwareGenres(doc *nfoDocument) []string { if doc == nil { return nil } - values := make([]string, 0, len(doc.Genres)+len(doc.Tags)+len(doc.Actors)+4) + values := make([]string, 0, len(doc.Genres)+len(doc.Tags)+4) values = append(values, doc.Genres...) values = append(values, doc.Tags...) for _, value := range []string{doc.Studio, doc.Maker, doc.Publisher, doc.Label} { @@ -69,18 +69,8 @@ func adultAwareGenres(doc *nfoDocument) []string { values = append(values, cleanXMLText(value)) } } - for _, value := range doc.Directors { - if cleanXMLText(value) != "" { - values = append(values, cleanXMLText(value)) - } - } - for _, actor := range doc.Actors { - if cleanXMLText(actor.Name) != "" { - values = append(values, cleanXMLText(actor.Name)) - } else if cleanXMLText(actor.Role) != "" { - values = append(values, cleanXMLText(actor.Role)) - } - } + // Cast and crew are intentionally not folded into genres. They are read + // from NFO/TMDb on detail requests and kept only in the in-memory cache. return values } diff --git a/internal/service/metatube.go b/internal/service/metatube.go index 4cae2c9..d3fb3b3 100644 --- a/internal/service/metatube.go +++ b/internal/service/metatube.go @@ -276,6 +276,64 @@ func (p *MetaTubeProvider) applyAuthHeader(req *http.Request, token string) { req.Header.Set("User-Agent", "MeBox/1.0 (MetaTube Client)") } +func metaTubePeople(movie *MetaTubeMovieInfo, enableActor bool) []map[string]any { + if movie == nil { + return nil + } + people := make([]map[string]any, 0, len(movie.Actors)+len(movie.Directors)+1) + add := func(name, personType string) { + name = strings.TrimSpace(name) + if name == "" { + return + } + personID := embyPersonID(name, personType) + for _, existing := range people { + if existing["Id"] == personID { + return + } + } + people = append(people, map[string]any{ + "Id": personID, + "Name": name, + "Type": personType, + "Role": personType, + }) + } + if enableActor { + for _, actor := range movie.Actors { + add(actor, "Actor") + } + } + add(movie.Director, "Director") + for _, director := range movie.Directors { + add(director, "Director") + } + return people +} + +func metaTubePeopleFromActors(actors []string) []map[string]any { + people := make([]map[string]any, 0, len(actors)) + seen := map[string]bool{} + for _, actor := range actors { + actor = strings.TrimSpace(actor) + if actor == "" { + continue + } + personID := embyPersonID(actor, "Actor") + if seen[personID] { + continue + } + seen[personID] = true + people = append(people, map[string]any{ + "Id": personID, + "Name": actor, + "Type": "Actor", + "Role": "Actor", + }) + } + return people +} + func (p *MetaTubeProvider) convertSearchResultToMatch(cfg MetaTubeConfig, query string, res *MetaTubeSearchResult) *Match { if res == nil { return nil @@ -293,13 +351,6 @@ func (p *MetaTubeProvider) convertSearchResultToMatch(cfg MetaTubeConfig, query year := parseYearFromDate(res.ReleaseDate) - genres := make([]string, 0, len(res.Actors)) - for _, a := range res.Actors { - if strings.TrimSpace(a) != "" { - genres = append(genres, strings.TrimSpace(a)) - } - } - posterSource := firstNonEmpty(res.BigCoverURL, res.CoverURL, res.BigThumbURL, res.ThumbURL) posterURL, backdropURL := metaTubeArtworkURLs(cfg, res.Provider, res.ID, posterSource) if posterURL == "" { @@ -319,8 +370,8 @@ func (p *MetaTubeProvider) convertSearchResultToMatch(cfg MetaTubeConfig, query Year: year, ReleaseDate: cleanDateString(res.ReleaseDate), Rating: res.Score, - Genres: genres, NSFW: true, + People: metaTubePeopleFromActors(res.Actors), DoubanID: res.ID, // 借用字段存储原始 ID 便于详情反查 TheTVDBID: res.Provider, // 借用字段存储 Provider } @@ -357,7 +408,7 @@ func (p *MetaTubeProvider) convertMovieInfoToMatch(cfg MetaTubeConfig, movie *Me } } - genres := make([]string, 0, len(movie.Genres)+len(movie.Actors)+4) + genres := make([]string, 0, len(movie.Genres)+2) for _, g := range movie.Genres { if strings.TrimSpace(g) != "" { genres = append(genres, strings.TrimSpace(g)) @@ -369,19 +420,6 @@ func (p *MetaTubeProvider) convertMovieInfoToMatch(cfg MetaTubeConfig, movie *Me if strings.TrimSpace(movie.Label) != "" && movie.Label != movie.Maker { genres = append(genres, strings.TrimSpace(movie.Label)) } - for _, a := range movie.Actors { - if strings.TrimSpace(a) != "" { - genres = append(genres, strings.TrimSpace(a)) - } - } - if strings.TrimSpace(movie.Director) != "" { - genres = append(genres, strings.TrimSpace(movie.Director)) - } - for _, d := range movie.Directors { - if strings.TrimSpace(d) != "" { - genres = append(genres, strings.TrimSpace(d)) - } - } return &Match{ Provider: "metatube", @@ -396,6 +434,7 @@ func (p *MetaTubeProvider) convertMovieInfoToMatch(cfg MetaTubeConfig, movie *Me Rating: movie.Score, Genres: dedupeStrings(genres), NSFW: true, + People: metaTubePeople(movie, cfg.EnableActor), DoubanID: movie.ID, TheTVDBID: movie.Provider, } diff --git a/internal/service/metatube_test.go b/internal/service/metatube_test.go index 040bdc2..1dd32d1 100644 --- a/internal/service/metatube_test.go +++ b/internal/service/metatube_test.go @@ -74,8 +74,11 @@ func TestMetaTubeProviderSearch(t *testing.T) { if !m.NSFW { t.Errorf("expected NSFW true") } - if len(m.Genres) != 1 || m.Genres[0] != "相沢みなみ" { - t.Errorf("unexpected genres: %v", m.Genres) + if len(m.Genres) != 0 { + t.Errorf("actor names must not be persisted as genres: %v", m.Genres) + } + if len(m.People) != 1 || m.People[0]["Name"] != "相沢みなみ" || m.People[0]["Type"] != "Actor" { + t.Errorf("unexpected people: %#v", m.People) } if want := server.URL + "/v1/images/primary/javdb/123456?auto=false&pos=-1&quality=90&ratio=-1&url=https%3A%2F%2Fexample.com%2Fcover.jpg"; m.PosterURL != want { t.Errorf("poster URL = %q, want %q", m.PosterURL, want) diff --git a/internal/service/service_builder.go b/internal/service/service_builder.go index e32e9bd..4d920f2 100644 --- a/internal/service/service_builder.go +++ b/internal/service/service_builder.go @@ -106,7 +106,7 @@ func (b *serviceContainerBuilder) initContentServices() { b.c.FileManager = NewFileManagerService(b.cfg, b.log, b.repos) b.c.DLNA = NewDLNAService(b.log) b.c.Storage = NewStorageService(b.log, b.repos) - b.c.Emby = NewEmbyService(b.cfg, b.log, b.repos) + b.c.Emby = NewEmbyService(b.cfg, b.log, b.repos).SetTMDbProvider(b.c.TMDb).SetAdultProvider(b.c.Scraper.adult) b.c.EmbyRemote = NewEmbyRemoteService(b.cfg, b.log, b.repos, b.c.Crypto).SetRuntimeCache(b.c.Cache) b.c.Emby.SetEmbyRemote(b.c.EmbyRemote) b.c.Backup = NewBackupService(b.cfg, b.log, b.repos.DB) diff --git a/internal/service/tmdb_people.go b/internal/service/tmdb_people.go new file mode 100644 index 0000000..d121f4b --- /dev/null +++ b/internal/service/tmdb_people.go @@ -0,0 +1,267 @@ +package service + +import ( + "context" + "fmt" + "net/url" + "regexp" + "strconv" + "strings" +) + +const ( + tmdbPersonIDPrefix = "person~tmdb~" + tmdbProfileTagPrefix = "tmdb:" + tmdbPeopleMax = 80 +) + +var tmdbProfilePathRE = regexp.MustCompile(`^/[A-Za-z0-9._-]+$`) + +type tmdbCreditCast struct { + ID int `json:"id"` + Name string `json:"name"` + Character string `json:"character"` + ProfilePath string `json:"profile_path"` + Order int `json:"order"` + Roles []struct { + Character string `json:"character"` + } `json:"roles"` +} + +type tmdbCreditCrew struct { + ID int `json:"id"` + Name string `json:"name"` + Job string `json:"job"` + Department string `json:"department"` + ProfilePath string `json:"profile_path"` + Jobs []struct { + Job string `json:"job"` + } `json:"jobs"` +} + +type tmdbCreditsResponse struct { + Cast []tmdbCreditCast `json:"cast"` + Crew []tmdbCreditCrew `json:"crew"` +} + +// GetPeople fetches cast/crew for a movie or series from TMDb. Nothing is +// persisted; callers should keep the returned entries in a short-lived cache. +func (t *TMDbProvider) GetPeople(ctx context.Context, tmdbID int, mediaType string) ([]map[string]any, error) { + if t == nil || t.cfg == nil || tmdbID <= 0 { + return nil, nil + } + apiKey := t.resolveAPIKey(ctx) + if strings.TrimSpace(apiKey) == "" { + return nil, nil + } + mediaType = strings.ToLower(strings.TrimSpace(mediaType)) + endpoint := "/movie/" + strconv.Itoa(tmdbID) + "/credits" + if mediaType == "tv" { + endpoint = "/tv/" + strconv.Itoa(tmdbID) + "/aggregate_credits" + } + q := url.Values{} + q.Set("api_key", apiKey) + q.Set("language", "zh-CN") + var out tmdbCreditsResponse + if err := t.getJSON(ctx, t.resolveBaseURL(ctx)+endpoint+"?"+q.Encode(), &out); err != nil { + return nil, err + } + + people := make([]map[string]any, 0, 32) + seen := map[string]int{} + for _, cast := range out.Cast { + name := strings.TrimSpace(cast.Name) + if name == "" || cast.ID <= 0 { + continue + } + role := strings.TrimSpace(cast.Character) + if role == "" { + for _, item := range cast.Roles { + if role = strings.TrimSpace(item.Character); role != "" { + break + } + } + } + if role == "" { + role = "Actor" + } + person := newTMDbPerson(cast.ID, name, "Actor", role, cast.ProfilePath) + key := personIdentity(person) + if idx, ok := seen[key]; ok { + mergePersonRole(people[idx], role) + continue + } + seen[key] = len(people) + people = append(people, person) + if len(people) >= tmdbPeopleMax { + break + } + } + + for _, crew := range out.Crew { + name := strings.TrimSpace(crew.Name) + if name == "" || crew.ID <= 0 { + continue + } + jobs := make([]string, 0, 1+len(crew.Jobs)) + if strings.TrimSpace(crew.Job) != "" { + jobs = append(jobs, strings.TrimSpace(crew.Job)) + } + for _, item := range crew.Jobs { + if job := strings.TrimSpace(item.Job); job != "" { + jobs = append(jobs, job) + } + } + jobs = deduplicateNonEmpty(jobs) + added := false + for _, job := range jobs { + personType := embyPersonTypeForJob(job) + if personType == "" { + continue + } + person := newTMDbPerson(crew.ID, name, personType, job, crew.ProfilePath) + key := personIdentity(person) + if idx, ok := seen[key]; ok { + mergePersonRole(people[idx], job) + } else if len(people) < tmdbPeopleMax { + seen[key] = len(people) + people = append(people, person) + } + added = true + } + if !added && len(people) >= tmdbPeopleMax { + break + } + } + return people, nil +} + +func newTMDbPerson(personID int, name, personType, role, profilePath string) map[string]any { + person := map[string]any{ + "Id": tmdbPersonID(personID), + "Name": name, + "Type": personType, + "Role": role, + } + if tag := tmdbProfileTag(profilePath); tag != "" { + person["PrimaryImageTag"] = tag + } + return person +} + +func personIdentity(person map[string]any) string { + return fmt.Sprint(person["Id"]) + ":" + fmt.Sprint(person["Type"]) +} + +func mergePersonRole(person map[string]any, role string) { + role = strings.TrimSpace(role) + if role == "" { + return + } + existing := strings.TrimSpace(fmt.Sprint(person["Role"])) + if existing == "" { + person["Role"] = role + return + } + for _, part := range strings.Split(existing, " / ") { + if strings.EqualFold(strings.TrimSpace(part), role) { + return + } + } + person["Role"] = existing + " / " + role +} + +func deduplicateNonEmpty(values []string) []string { + out := make([]string, 0, len(values)) + seen := map[string]bool{} + for _, value := range values { + value = strings.TrimSpace(value) + key := strings.ToLower(value) + if value == "" || seen[key] { + continue + } + seen[key] = true + out = append(out, value) + } + return out +} + +func embyPersonTypeForJob(job string) string { + switch strings.ToLower(strings.TrimSpace(job)) { + case "director": + return "Director" + case "writer", "screenplay", "story", "teleplay", "author": + return "Writer" + case "producer", "executive producer", "co-producer", "associate producer": + return "Producer" + case "composer", "original music composer", "music": + return "Composer" + default: + return "" + } +} + +func tmdbPersonID(personID int) string { + return tmdbPersonIDPrefix + strconv.Itoa(personID) +} + +func parseTMDbPersonID(id string) (int, bool) { + id = strings.TrimSpace(id) + if !strings.HasPrefix(id, tmdbPersonIDPrefix) { + return 0, false + } + raw := strings.TrimPrefix(id, tmdbPersonIDPrefix) + personID, err := strconv.Atoi(raw) + return personID, err == nil && personID > 0 +} + +func tmdbProfileTag(profilePath string) string { + profilePath = strings.TrimSpace(profilePath) + if !tmdbProfilePathRE.MatchString(profilePath) { + return "" + } + return tmdbProfileTagPrefix + profilePath +} + +func tmdbProfilePathFromTag(tag string) string { + tag = strings.TrimSpace(tag) + if !strings.HasPrefix(tag, tmdbProfileTagPrefix) { + return "" + } + profilePath := strings.TrimPrefix(tag, tmdbProfileTagPrefix) + if !tmdbProfilePathRE.MatchString(profilePath) { + return "" + } + return profilePath +} + +// ProfileImageURL builds the public TMDb image CDN URL for a profile path. +func (t *TMDbProvider) ProfileImageURL(profilePath string) string { + if t == nil || !tmdbProfilePathRE.MatchString(strings.TrimSpace(profilePath)) { + return "" + } + return strings.TrimRight(t.imgCDN, "/") + "/w300" + strings.TrimSpace(profilePath) +} + +// PersonProfilePathByID resolves a person's current profile path directly +// from TMDb. Used when the client does not return the PrimaryImageTag. +func (t *TMDbProvider) PersonProfilePathByID(ctx context.Context, personID int) (string, error) { + if t == nil || t.cfg == nil || personID <= 0 { + return "", nil + } + apiKey := t.resolveAPIKey(ctx) + if strings.TrimSpace(apiKey) == "" { + return "", nil + } + q := url.Values{} + q.Set("api_key", apiKey) + q.Set("language", "zh-CN") + endpoint := t.resolveBaseURL(ctx) + "/person/" + strconv.Itoa(personID) + "?" + q.Encode() + var out struct { + ProfilePath string `json:"profile_path"` + } + if err := t.getJSON(ctx, endpoint, &out); err != nil { + return "", err + } + return strings.TrimSpace(out.ProfilePath), nil +} diff --git a/internal/service/tmdb_people_test.go b/internal/service/tmdb_people_test.go new file mode 100644 index 0000000..6f3141e --- /dev/null +++ b/internal/service/tmdb_people_test.go @@ -0,0 +1,83 @@ +package service + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "go.uber.org/zap" + + "github.com/truewhile/MeBox/internal/config" +) + +func TestTMDbGetPeopleAndProfileImage(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/movie/11/credits" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "cast": []map[string]any{ + {"id": 101, "name": "演员甲", "character": "主角", "profile_path": "/actor.jpg", "order": 0}, + }, + "crew": []map[string]any{ + {"id": 201, "name": "导演甲", "job": "Director", "department": "Directing", "profile_path": "/director.jpg"}, + {"id": 202, "name": "编剧甲", "job": "Writer", "department": "Writing", "profile_path": "/writer.jpg"}, + }, + }) + })) + defer server.Close() + + cfg := &config.Config{Secrets: config.SecretsConfig{ + TMDbAPIKey: "test-key", + TMDbAPIProxy: server.URL, + TMDbImageProxy: "https://image.example/t/p", + }} + provider := NewTMDbProvider(cfg, zap.NewNop(), nil) + people, err := provider.GetPeople(context.Background(), 11, "movie") + if err != nil { + t.Fatal(err) + } + if len(people) != 3 { + t.Fatalf("people=%d want 3: %#v", len(people), people) + } + if people[0]["Id"] != "person~tmdb~101" || people[0]["Role"] != "主角" { + t.Fatalf("unexpected actor: %#v", people[0]) + } + if people[0]["PrimaryImageTag"] != "tmdb:/actor.jpg" { + t.Fatalf("unexpected actor image tag: %#v", people[0]) + } + if got := provider.ProfileImageURL("/actor.jpg"); got != "https://image.example/t/p/w300/actor.jpg" { + t.Fatalf("profile image URL=%q", got) + } +} + +func TestTMDbGetPeopleUsesAggregateCreditsForTV(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/tv/22/aggregate_credits" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "cast": []map[string]any{ + {"id": 301, "name": "演员乙", "roles": []map[string]any{{"character": "角色乙"}}, "profile_path": "/tv-actor.jpg"}, + }, + "crew": []map[string]any{}, + }) + })) + defer server.Close() + + cfg := &config.Config{Secrets: config.SecretsConfig{TMDbAPIKey: "test-key", TMDbAPIProxy: server.URL}} + provider := NewTMDbProvider(cfg, zap.NewNop(), nil) + people, err := provider.GetPeople(context.Background(), 22, "tv") + if err != nil { + t.Fatal(err) + } + if len(people) != 1 || people[0]["Role"] != "角色乙" { + t.Fatalf("unexpected TV people: %#v", people) + } +} diff --git a/internal/service/tmdb_types.go b/internal/service/tmdb_types.go index 64ba330..a800b59 100644 --- a/internal/service/tmdb_types.go +++ b/internal/service/tmdb_types.go @@ -22,6 +22,7 @@ type Match struct { Countries []string `json:"countries,omitempty"` Genres []string `json:"genres,omitempty"` Aliases []string `json:"aliases,omitempty"` + People []map[string]any `json:"-"` NSFW bool `json:"nsfw,omitempty"` SearchKeyword string `json:"-"` } diff --git a/web/src/components/LayoutHeaderSections.tsx b/web/src/components/LayoutHeaderSections.tsx index 2b7601e..dc3af40 100644 --- a/web/src/components/LayoutHeaderSections.tsx +++ b/web/src/components/LayoutHeaderSections.tsx @@ -1,5 +1,5 @@ -import { useEffect, useRef, useState } from 'react' -import { Link, useNavigate } from 'react-router-dom' +import { useEffect, useLayoutEffect, useRef, useState } from 'react' +import { Link, useLocation, useNavigate } from 'react-router-dom' import { AnimatePresence, motion } from 'framer-motion' import { ArrowLeft, Film, LoaderCircle, Menu, Search, Star, X } from 'lucide-react' @@ -118,7 +118,9 @@ function LayoutHeaderSearch() { const [results, setResults] = useState([]) const [hasMore, setHasMore] = useState(false) const containerRef = useRef(null) + const inputRef = useRef(null) const isOpenRef = useRef(false) + const suppressInputRef = useRef(false) // 递增序号守卫:快速连续输入时丢弃过期响应 const searchSeqRef = useRef(0) const pageRef = useRef(0) @@ -126,12 +128,38 @@ function LayoutHeaderSearch() { const resultsRef = useRef([]) const hasMoreRef = useRef(false) const navigate = useNavigate() + const location = useLocation() + const locationKey = `${location.pathname}${location.search}` + const prevLocationKeyRef = useRef(locationKey) const setSearchOpen = (open: boolean) => { isOpenRef.current = open setIsOpen(open) } + const dismissSearch = (clearQuery: boolean) => { + suppressInputRef.current = true + setSearchOpen(false) + if (clearQuery) { + searchSeqRef.current += 1 + pageRef.current = 0 + loadingMoreRef.current = false + resultsRef.current = [] + hasMoreRef.current = false + setQuery('') + setResults([]) + setLoading(false) + setLoadingMore(false) + setHasMore(false) + } + inputRef.current?.blur() + window.setTimeout(() => { + suppressInputRef.current = false + }, 0) + } + const dismissSearchRef = useRef(dismissSearch) + dismissSearchRef.current = dismissSearch + useEffect(() => { const trimmed = query.trim() if (!trimmed) { @@ -170,7 +198,7 @@ function LayoutHeaderSearch() { hasMoreRef.current = more setResults(items) setHasMore(more) - if (isOpenRef.current) setIsOpen(true) + if (isOpenRef.current && !suppressInputRef.current) setIsOpen(true) } catch { // 请求失败时保持空结果,用户继续输入或滚动时会重新请求 } finally { @@ -181,15 +209,21 @@ function LayoutHeaderSearch() { return () => clearTimeout(timer) }, [query]) + useLayoutEffect(() => { + if (prevLocationKeyRef.current === locationKey) return + prevLocationKeyRef.current = locationKey + dismissSearchRef.current(true) + }, [locationKey]) + useEffect(() => { - function handleClickOutside(e: MouseEvent) { - if (containerRef.current && !containerRef.current.contains(e.target as Node)) { - isOpenRef.current = false - setIsOpen(false) - } + function handlePointerDown(e: PointerEvent) { + if (!isOpenRef.current) return + const root = containerRef.current + if (root && e.composedPath().includes(root)) return + dismissSearchRef.current(false) } - document.addEventListener('mousedown', handleClickOutside) - return () => document.removeEventListener('mousedown', handleClickOutside) + document.addEventListener('pointerdown', handlePointerDown, true) + return () => document.removeEventListener('pointerdown', handlePointerDown, true) }, []) const loadMore = async () => { @@ -227,14 +261,15 @@ function LayoutHeaderSearch() { } const handleSelect = (item: Media) => { - setSearchOpen(false) - setQuery('') - navigate(favouriteMediaLink(item)) + if (suppressInputRef.current) return + const to = favouriteMediaLink(item) + dismissSearch(true) + navigate(to) } const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Escape') { - setSearchOpen(false) + dismissSearch(false) } else if (e.key === 'Enter' && results.length > 0) { handleSelect(results[0]) } @@ -248,14 +283,21 @@ function LayoutHeaderSearch() { className="absolute left-3 text-[var(--app-muted)] pointer-events-none transition-colors group-focus-within:text-brand-500 sm:left-3.5 sm:text-[16px]" /> { + if (suppressInputRef.current) return const nextQuery = e.target.value setQuery(nextQuery) - setSearchOpen(Boolean(nextQuery.trim())) + if (nextQuery.trim() && document.activeElement === inputRef.current) { + setSearchOpen(true) + } else if (!nextQuery.trim()) { + setSearchOpen(false) + } }} onFocus={() => { + if (suppressInputRef.current) return if (query.trim()) setSearchOpen(true) }} onKeyDown={handleKeyDown} @@ -268,9 +310,7 @@ function LayoutHeaderSearch() {