diff --git a/internal/api/handlers/metadata_ai.go b/internal/api/handlers/metadata_ai.go index 16848b03..cbc28c9f 100644 --- a/internal/api/handlers/metadata_ai.go +++ b/internal/api/handlers/metadata_ai.go @@ -1,6 +1,7 @@ package handlers import ( + "context" "encoding/json" "errors" "log/slog" @@ -13,8 +14,27 @@ import ( apimw "github.com/Silo-Server/silo-server/internal/api/middleware" "github.com/Silo-Server/silo-server/internal/catalog" "github.com/Silo-Server/silo-server/internal/metadata/translation" + "github.com/Silo-Server/silo-server/internal/models" ) +type metadataAIItemAccess interface { + GetByID(ctx context.Context, contentID string) (*models.MediaItem, error) + EnsureAccessible(ctx context.Context, contentID string, filter catalog.AccessFilter) error +} + +type metadataAISeasonLookup interface { + GetByID(ctx context.Context, contentID string) (*models.Season, error) +} + +type metadataAIEpisodeLookup interface { + GetByID(ctx context.Context, contentID string) (*models.Episode, error) +} + +type metadataAITarget struct { + kind translation.TargetKind + accessContentID string +} + // MetadataAIHandler exposes AI translation of catalog descriptions into the // localization tables. The admin routes are mounted under the per-item // metadata curation guard; the on-view route is viewer-facing and enforces @@ -22,7 +42,11 @@ import ( type MetadataAIHandler struct { service *translation.Service // ItemAccess authorizes the viewer-facing on-view route; nil disables it. - ItemAccess *catalog.ItemRepository + ItemAccess metadataAIItemAccess + // SeasonLookup and EpisodeLookup let the viewer route resolve non-item + // detail pages to the parent series for authorization. + SeasonLookup metadataAISeasonLookup + EpisodeLookup metadataAIEpisodeLookup } // NewMetadataAIHandler creates a handler backed by the given service. @@ -82,7 +106,16 @@ func (h *MetadataAIHandler) HandleTranslateOnView(w http.ResponseWriter, r *http UserID: scope.UserID, ProfileID: scope.ProfileID, } - if err := h.ItemAccess.EnsureAccessible(r.Context(), contentID, filter); err != nil { + target, err := h.resolveTranslationTarget(r.Context(), contentID) + if err != nil { + if errors.Is(err, catalog.ErrItemNotFound) { + writeError(w, http.StatusNotFound, "not_found", "Item not found") + return + } + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to authorize item") + return + } + if err := h.ItemAccess.EnsureAccessible(r.Context(), target.accessContentID, filter); err != nil { if errors.Is(err, catalog.ErrItemNotFound) { writeError(w, http.StatusNotFound, "not_found", "Item not found") return @@ -96,7 +129,7 @@ func (h *MetadataAIHandler) HandleTranslateOnView(w http.ResponseWriter, r *http requestedBy = &userID } - job, err := h.service.RequestOnView(r.Context(), contentID, req.TargetLanguage, requestedBy) + job, err := h.service.RequestOnView(r.Context(), target.kind, contentID, req.TargetLanguage, requestedBy) if err != nil { switch { case errors.Is(err, translation.ErrNotConfigured): @@ -115,6 +148,51 @@ func (h *MetadataAIHandler) HandleTranslateOnView(w http.ResponseWriter, r *http writeJSON(w, http.StatusAccepted, map[string]any{"job": job}) } +func (h *MetadataAIHandler) resolveTranslationTarget(ctx context.Context, contentID string) (metadataAITarget, error) { + if h.ItemAccess == nil { + return metadataAITarget{}, catalog.ErrItemNotFound + } + + item, err := h.ItemAccess.GetByID(ctx, contentID) + switch { + case err == nil: + if item == nil { + return metadataAITarget{}, catalog.ErrItemNotFound + } + return metadataAITarget{kind: translation.TargetItem, accessContentID: contentID}, nil + case !errors.Is(err, catalog.ErrItemNotFound): + return metadataAITarget{}, err + } + + if h.SeasonLookup != nil { + season, err := h.SeasonLookup.GetByID(ctx, contentID) + switch { + case err == nil: + if season == nil { + return metadataAITarget{}, catalog.ErrItemNotFound + } + return metadataAITarget{kind: translation.TargetSeason, accessContentID: season.SeriesID}, nil + case !errors.Is(err, catalog.ErrSeasonNotFound): + return metadataAITarget{}, err + } + } + + if h.EpisodeLookup != nil { + episode, err := h.EpisodeLookup.GetByID(ctx, contentID) + switch { + case err == nil: + if episode == nil { + return metadataAITarget{}, catalog.ErrItemNotFound + } + return metadataAITarget{kind: translation.TargetEpisode, accessContentID: episode.SeriesID}, nil + case !errors.Is(err, catalog.ErrEpisodeNotFound): + return metadataAITarget{}, err + } + } + + return metadataAITarget{}, catalog.ErrItemNotFound +} + type translateMetadataRequest struct { TargetLanguage string `json:"target_language"` IncludeChildren *bool `json:"include_children"` // default true @@ -134,9 +212,18 @@ func (h *MetadataAIHandler) HandleTranslate(w http.ResponseWriter, r *http.Reque writeError(w, http.StatusBadRequest, "bad_request", "target_language is required") return } - includeChildren := true + target, err := h.resolveTranslationTarget(r.Context(), contentID) + if err != nil { + if errors.Is(err, catalog.ErrItemNotFound) { + writeError(w, http.StatusNotFound, "not_found", "Item not found") + return + } + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to resolve item") + return + } + includeChildren := target.kind == translation.TargetItem if req.IncludeChildren != nil { - includeChildren = *req.IncludeChildren + includeChildren = *req.IncludeChildren && target.kind == translation.TargetItem } var requestedBy *int @@ -145,7 +232,7 @@ func (h *MetadataAIHandler) HandleTranslate(w http.ResponseWriter, r *http.Reque } job, err := h.service.Enqueue(r.Context(), translation.JobRequest{ - TargetKind: translation.TargetItem, + TargetKind: target.kind, ContentID: contentID, TargetLanguage: req.TargetLanguage, IncludeChildren: includeChildren, diff --git a/internal/api/handlers/metadata_ai_test.go b/internal/api/handlers/metadata_ai_test.go new file mode 100644 index 00000000..35d7c08f --- /dev/null +++ b/internal/api/handlers/metadata_ai_test.go @@ -0,0 +1,124 @@ +package handlers + +import ( + "context" + "errors" + "testing" + + "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/metadata/translation" + "github.com/Silo-Server/silo-server/internal/models" +) + +type fakeMetadataAIItemAccess struct { + items map[string]*models.MediaItem + ensureErr map[string]error + checked []string +} + +func (f *fakeMetadataAIItemAccess) GetByID(_ context.Context, contentID string) (*models.MediaItem, error) { + if item := f.items[contentID]; item != nil { + return item, nil + } + return nil, catalog.ErrItemNotFound +} + +func (f *fakeMetadataAIItemAccess) EnsureAccessible(_ context.Context, contentID string, _ catalog.AccessFilter) error { + f.checked = append(f.checked, contentID) + return f.ensureErr[contentID] +} + +type fakeMetadataAISeasonLookup map[string]*models.Season + +func (f fakeMetadataAISeasonLookup) GetByID(_ context.Context, contentID string) (*models.Season, error) { + if season := f[contentID]; season != nil { + return season, nil + } + return nil, catalog.ErrSeasonNotFound +} + +type fakeMetadataAIEpisodeLookup map[string]*models.Episode + +func (f fakeMetadataAIEpisodeLookup) GetByID(_ context.Context, contentID string) (*models.Episode, error) { + if episode := f[contentID]; episode != nil { + return episode, nil + } + return nil, catalog.ErrEpisodeNotFound +} + +func TestMetadataAIResolveOnViewTarget(t *testing.T) { + itemAccess := &fakeMetadataAIItemAccess{ + items: map[string]*models.MediaItem{ + "movie-1": {ContentID: "movie-1", Type: "movie"}, + }, + ensureErr: map[string]error{}, + } + handler := &MetadataAIHandler{ + ItemAccess: itemAccess, + SeasonLookup: fakeMetadataAISeasonLookup{ + "season-1": {ContentID: "season-1", SeriesID: "series-1"}, + }, + EpisodeLookup: fakeMetadataAIEpisodeLookup{ + "episode-1": {ContentID: "episode-1", SeriesID: "series-1"}, + }, + } + + tests := []struct { + name string + contentID string + wantKind translation.TargetKind + wantAccess string + }{ + { + name: "item", + contentID: "movie-1", + wantKind: translation.TargetItem, + wantAccess: "movie-1", + }, + { + name: "season", + contentID: "season-1", + wantKind: translation.TargetSeason, + wantAccess: "series-1", + }, + { + name: "episode", + contentID: "episode-1", + wantKind: translation.TargetEpisode, + wantAccess: "series-1", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + itemAccess.checked = nil + got, err := handler.resolveTranslationTarget(context.Background(), tc.contentID) + if err != nil { + t.Fatalf("resolveTranslationTarget: %v", err) + } + if got.kind != tc.wantKind { + t.Fatalf("kind = %s, want %s", got.kind, tc.wantKind) + } + if got.accessContentID != tc.wantAccess { + t.Fatalf("accessContentID = %q, want %q", got.accessContentID, tc.wantAccess) + } + }) + } +} + +func TestMetadataAIResolveTranslationTargetReportsMissingContent(t *testing.T) { + itemAccess := &fakeMetadataAIItemAccess{ + items: map[string]*models.MediaItem{}, + ensureErr: map[string]error{}, + } + handler := &MetadataAIHandler{ + ItemAccess: itemAccess, + SeasonLookup: fakeMetadataAISeasonLookup{}, + EpisodeLookup: fakeMetadataAIEpisodeLookup{}, + } + + _, err := handler.resolveTranslationTarget(context.Background(), "missing") + if !errors.Is(err, catalog.ErrItemNotFound) { + t.Fatalf("err = %v, want %v", err, catalog.ErrItemNotFound) + } +} diff --git a/internal/api/router.go b/internal/api/router.go index 498521f8..3127d6d5 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -2047,6 +2047,8 @@ func NewRouter(deps Dependencies) chi.Router { r.Get("/metadata/ai/status", metadataAIHandler.HandleStatus) if itemRepo != nil { metadataAIHandler.ItemAccess = itemRepo + metadataAIHandler.SeasonLookup = seasonRepo + metadataAIHandler.EpisodeLookup = episodeRepo r.Post("/items/{id}/translate-description", metadataAIHandler.HandleTranslateOnView) } } else { diff --git a/internal/catalog/detail.go b/internal/catalog/detail.go index 066efcc2..725d4e79 100644 --- a/internal/catalog/detail.go +++ b/internal/catalog/detail.go @@ -683,6 +683,40 @@ func (s *DetailService) PendingTranslationLanguage(ctx context.Context, item *mo return language } +// PendingSeasonTranslationLanguage is the season-row equivalent of +// PendingTranslationLanguage. +func (s *DetailService) PendingSeasonTranslationLanguage(ctx context.Context, season *models.Season, filter AccessFilter) string { + if season == nil || strings.TrimSpace(season.Overview) == "" || s.seasonLocRepo == nil { + return "" + } + language, err := s.resolvePresentationLanguage(ctx, filter) + if err != nil || language == "" || sameMetadataLanguage(season.DefaultMetadataLanguage, language) { + return "" + } + loc, err := s.seasonLocRepo.Get(ctx, season.ContentID, language) + if err != nil || (loc != nil && loc.Overview != "") { + return "" + } + return language +} + +// PendingEpisodeTranslationLanguage is the episode-row equivalent of +// PendingTranslationLanguage. +func (s *DetailService) PendingEpisodeTranslationLanguage(ctx context.Context, episode *models.Episode, filter AccessFilter) string { + if episode == nil || strings.TrimSpace(episode.Overview) == "" || s.episodeLocRepo == nil { + return "" + } + language, err := s.resolvePresentationLanguage(ctx, filter) + if err != nil || language == "" || sameMetadataLanguage(episode.DefaultMetadataLanguage, language) { + return "" + } + loc, err := s.episodeLocRepo.Get(ctx, episode.ContentID, language) + if err != nil || (loc != nil && loc.Overview != "") { + return "" + } + return language +} + func (s *DetailService) validatePresentationItemAccess(ctx context.Context, filter AccessFilter, contentID string) error { if filter.PresentationLibraryID == nil { return nil @@ -1755,6 +1789,7 @@ func clearSentinel(s string) string { } func (s *DetailService) buildSeasonDetail(ctx context.Context, season *models.Season, filter AccessFilter) (*ItemDetail, error) { + pendingTranslation := s.PendingSeasonTranslationLanguage(ctx, season, filter) localizedSeason, err := s.LocalizeSeasonModel(ctx, season, filter) if err != nil { return nil, fmt.Errorf("localizing season detail: %w", err) @@ -1790,22 +1825,23 @@ func (s *DetailService) buildSeasonDetail(ctx context.Context, season *models.Se seasonNumber := season.SeasonNumber castCredits, crewCredits := s.fetchCredits(ctx, season.SeriesID) detail := &ItemDetail{ - ContentID: season.ContentID, - Type: "season", - Title: title, - Overview: season.Overview, - PosterThumbhash: season.PosterThumbhash, - BackdropThumbhash: series.BackdropThumbhash, - SeriesID: season.SeriesID, - SeriesTitle: series.Title, - SeasonNumber: &seasonNumber, - EpisodeCount: &episodeCount, - IsSpecials: season.SeasonNumber == 0, - Cast: castCredits, - Crew: crewCredits, - Versions: []FileVersion{}, - PlaybackVariants: []PlaybackVariant{}, - Subtitles: []SubtitleInfo{}, + ContentID: season.ContentID, + Type: "season", + Title: title, + Overview: season.Overview, + PendingTranslationLanguage: pendingTranslation, + PosterThumbhash: season.PosterThumbhash, + BackdropThumbhash: series.BackdropThumbhash, + SeriesID: season.SeriesID, + SeriesTitle: series.Title, + SeasonNumber: &seasonNumber, + EpisodeCount: &episodeCount, + IsSpecials: season.SeasonNumber == 0, + Cast: castCredits, + Crew: crewCredits, + Versions: []FileVersion{}, + PlaybackVariants: []PlaybackVariant{}, + Subtitles: []SubtitleInfo{}, } if season.AirDate != nil { airDate := season.AirDate.Format("2006-01-02") @@ -1818,6 +1854,7 @@ func (s *DetailService) buildSeasonDetail(ctx context.Context, season *models.Se } func (s *DetailService) buildEpisodeDetail(ctx context.Context, episode *models.Episode, seriesCtx *seriesDetailContext, filter AccessFilter) (*ItemDetail, error) { + pendingTranslation := s.PendingEpisodeTranslationLanguage(ctx, episode, filter) localizedEpisode, err := s.LocalizeEpisodeModel(ctx, episode, filter) if err != nil { return nil, fmt.Errorf("localizing episode detail: %w", err) @@ -1828,27 +1865,28 @@ func (s *DetailService) buildEpisodeDetail(ctx context.Context, episode *models. seasonNumber := episode.SeasonNumber episodeNumber := episode.EpisodeNumber detail := &ItemDetail{ - ContentID: episode.ContentID, - Type: "episode", - Title: episode.Title, - Overview: episode.Overview, - Runtime: episode.Runtime, - RatingIMDB: episode.RatingIMDB, - RatingTMDB: episode.RatingTMDB, - ImdbID: episode.ImdbID, - TmdbID: episode.TmdbID, - TvdbID: episode.TvdbID, - PosterThumbhash: episode.StillThumbhash, - BackdropThumbhash: series.BackdropThumbhash, - SeriesID: episode.SeriesID, - SeriesTitle: series.Title, - SeasonNumber: &seasonNumber, - EpisodeNumber: &episodeNumber, - Cast: seriesCtx.castCredits, - Crew: seriesCtx.crewCredits, - Versions: []FileVersion{}, - PlaybackVariants: []PlaybackVariant{}, - Subtitles: []SubtitleInfo{}, + ContentID: episode.ContentID, + Type: "episode", + Title: episode.Title, + Overview: episode.Overview, + PendingTranslationLanguage: pendingTranslation, + Runtime: episode.Runtime, + RatingIMDB: episode.RatingIMDB, + RatingTMDB: episode.RatingTMDB, + ImdbID: episode.ImdbID, + TmdbID: episode.TmdbID, + TvdbID: episode.TvdbID, + PosterThumbhash: episode.StillThumbhash, + BackdropThumbhash: series.BackdropThumbhash, + SeriesID: episode.SeriesID, + SeriesTitle: series.Title, + SeasonNumber: &seasonNumber, + EpisodeNumber: &episodeNumber, + Cast: seriesCtx.castCredits, + Crew: seriesCtx.crewCredits, + Versions: []FileVersion{}, + PlaybackVariants: []PlaybackVariant{}, + Subtitles: []SubtitleInfo{}, } if episode.AirDate != nil { airDate := episode.AirDate.Format("2006-01-02") diff --git a/internal/metadata/translation/service.go b/internal/metadata/translation/service.go index 561cfeca..a306ab69 100644 --- a/internal/metadata/translation/service.go +++ b/internal/metadata/translation/service.go @@ -201,10 +201,17 @@ func (s *Service) OnViewMode() string { return s.config().OnViewMode() } // cooldown window after a failed job for the same target+language: ordinary // page views must never hammer a broken endpoint. Returns the resulting job // (which may be the in-flight or recently failed one). -func (s *Service) RequestOnView(ctx context.Context, contentID, targetLanguage string, requestedBy *int) (*Job, error) { +func (s *Service) RequestOnView(ctx context.Context, targetKind TargetKind, contentID, targetLanguage string, requestedBy *int) (*Job, error) { if s.config().OnViewMode() == "off" { return nil, ErrNotConfigured } + switch targetKind { + case "": + targetKind = TargetItem + case TargetItem, TargetSeason, TargetEpisode: + default: + return nil, fmt.Errorf("%w: unsupported target kind %q", ErrInvalidRequest, targetKind) + } target, err := subtitles.NormalizeLanguageCode(targetLanguage) if err != nil { return nil, fmt.Errorf("%w: invalid target language %q", ErrInvalidRequest, targetLanguage) @@ -215,6 +222,9 @@ func (s *Service) RequestOnView(ctx context.Context, contentID, targetLanguage s return nil, err } for _, job := range jobs { + if job.TargetKind != targetKind { + continue + } if job.TargetLanguage != target { continue } @@ -226,10 +236,10 @@ func (s *Service) RequestOnView(ctx context.Context, contentID, targetLanguage s } return s.Enqueue(ctx, JobRequest{ - TargetKind: TargetItem, + TargetKind: targetKind, ContentID: contentID, TargetLanguage: target, - IncludeChildren: true, + IncludeChildren: targetKind == TargetItem, RequestedBy: requestedBy, }) } diff --git a/internal/metadata/translation/service_test.go b/internal/metadata/translation/service_test.go index 7d34c74c..52480c3c 100644 --- a/internal/metadata/translation/service_test.go +++ b/internal/metadata/translation/service_test.go @@ -508,7 +508,7 @@ func TestRequestOnViewCooldownAndGating(t *testing.T) { repo.jobs[failed.ID].UpdatedAt = time.Now().Add(-time.Minute) repo.mu.Unlock() - job, err := svc.RequestOnView(context.Background(), "series1", "fr", nil) + job, err := svc.RequestOnView(context.Background(), TargetItem, "series1", "fr", nil) if err != nil { t.Fatalf("RequestOnView: %v", err) } @@ -520,7 +520,7 @@ func TestRequestOnViewCooldownAndGating(t *testing.T) { repo.mu.Lock() repo.jobs[failed.ID].UpdatedAt = time.Now().Add(-2 * onViewFailureCooldown) repo.mu.Unlock() - job, err = svc.RequestOnView(context.Background(), "series1", "fr", nil) + job, err = svc.RequestOnView(context.Background(), TargetItem, "series1", "fr", nil) if err != nil { t.Fatalf("RequestOnView after cooldown: %v", err) } @@ -530,7 +530,7 @@ func TestRequestOnViewCooldownAndGating(t *testing.T) { waitDone(t, repo) // A different language is unaffected by the failure. - job2, err := svc.RequestOnView(context.Background(), "series1", "de", nil) + job2, err := svc.RequestOnView(context.Background(), TargetItem, "series1", "de", nil) if err != nil || job2.ID == failed.ID { t.Fatalf("other-language request blocked: job=%v err=%v", job2, err) } @@ -538,7 +538,44 @@ func TestRequestOnViewCooldownAndGating(t *testing.T) { // OnView off (zero-value config) refuses viewer requests outright. off := NewService(context.Background(), Config{Enabled: true, Configured: true, ChatModel: "m"}, repo, content, locs, chat.fn, nil, nil) - if _, err := off.RequestOnView(context.Background(), "series1", "fr", nil); err != ErrNotConfigured { + if _, err := off.RequestOnView(context.Background(), TargetItem, "series1", "fr", nil); err != ErrNotConfigured { t.Errorf("on_view=off err = %v, want ErrNotConfigured", err) } } + +func TestRequestOnViewUsesRequestedTargetKind(t *testing.T) { + repo, content, locs, chat := newFakeRepo(), seriesContent(), &fakeLocs{}, &upperChat{} + svc := testService(t, repo, content, locs, chat) + + seasonJob, err := svc.RequestOnView(context.Background(), TargetSeason, "sea1", "fr", nil) + if err != nil { + t.Fatalf("RequestOnView season: %v", err) + } + waitDone(t, repo) + if final := repo.job(seasonJob.ID); final.TargetKind != TargetSeason || final.IncludeChildren { + t.Fatalf("season job = %+v, want target season without children", final) + } + + episodeJob, err := svc.RequestOnView(context.Background(), TargetEpisode, "ep1", "fr", nil) + if err != nil { + t.Fatalf("RequestOnView episode: %v", err) + } + waitDone(t, repo) + if final := repo.job(episodeJob.ID); final.TargetKind != TargetEpisode || final.IncludeChildren { + t.Fatalf("episode job = %+v, want target episode without children", final) + } + + writes := locs.allWrites() + var sawSeason, sawEpisode bool + for _, write := range writes { + if write.kind == TargetSeason && write.contentID == "sea1" && write.overview == "SEASON ONE." { + sawSeason = true + } + if write.kind == TargetEpisode && write.contentID == "ep1" && write.overview == "PILOT EPISODE." { + sawEpisode = true + } + } + if !sawSeason || !sawEpisode { + t.Fatalf("missing target-kind writes: %+v", writes) + } +} diff --git a/web/src/components/EditMetadataDialog.tsx b/web/src/components/EditMetadataDialog.tsx index d212bc69..3fcb6464 100644 --- a/web/src/components/EditMetadataDialog.tsx +++ b/web/src/components/EditMetadataDialog.tsx @@ -340,7 +340,7 @@ export default function EditMetadataDialog({ item, open, onOpenChange }: EditMet /> - {(item.type === "movie" || item.type === "series") && ( + {["movie", "series", "season", "episode"].includes(item.type) && ( )} diff --git a/web/src/components/MetadataTranslatePanel.tsx b/web/src/components/MetadataTranslatePanel.tsx index 3a2ccf44..e7af5926 100644 --- a/web/src/components/MetadataTranslatePanel.tsx +++ b/web/src/components/MetadataTranslatePanel.tsx @@ -68,6 +68,13 @@ export function MetadataTranslatePanel({ item }: { item: ItemDetail }) { if (!enabled) return null; const busy = translateMutation.isPending || Boolean(activeJob); + const translatesChildren = item.type === "series"; + const description = + item.type === "series" + ? "Translates the overview and tagline plus all season and episode overviews" + : item.type === "movie" + ? "Translates the overview and tagline" + : "Translates the overview"; function start() { if (!targetLang) { @@ -75,7 +82,7 @@ export function MetadataTranslatePanel({ item }: { item: ItemDetail }) { return; } translateMutation.mutate( - { target_language: targetLang, include_children: true, force }, + { target_language: targetLang, include_children: translatesChildren, force }, { onSuccess: () => setWatching(true) }, ); } @@ -87,10 +94,8 @@ export function MetadataTranslatePanel({ item }: { item: ItemDetail }) { Translate with AI

- Translates the overview and tagline - {item.type === "series" ? ", plus all season and episode overviews," : ""} into the chosen - language. Translations are served to libraries using that metadata language; provider data - replaces them when it becomes available. + {description} into the chosen language. Translations are served to libraries using that + metadata language; provider data replaces them when it becomes available.

diff --git a/web/src/hooks/useOnViewTranslation.ts b/web/src/hooks/useOnViewTranslation.ts index 829471fe..64aec271 100644 --- a/web/src/hooks/useOnViewTranslation.ts +++ b/web/src/hooks/useOnViewTranslation.ts @@ -29,6 +29,8 @@ export function useOnViewTranslation(item: ItemDetail | undefined) { const contentId = item?.content_id ?? ""; const pendingLanguage = item?.pending_translation_language ?? ""; + const seriesId = item?.series_id; + const seasonNumber = item?.season_number; const [translating, setTranslating] = useState(false); // Tracks which item+language we already fired for, so auto mode triggers @@ -67,9 +69,14 @@ export function useOnViewTranslation(item: ItemDetail | undefined) { // Prefix invalidation covers the per-library detail key variants // (["catalog", "items", id, "detail", ]). void queryClient.invalidateQueries({ queryKey: ["catalog", "items", contentId] }); + if (seriesId && typeof seasonNumber === "number") { + void queryClient.invalidateQueries({ + queryKey: ["catalog", "series", seriesId, "seasons", seasonNumber], + }); + } }, POLL_INTERVAL_MS); return () => clearInterval(timer); - }, [translating, contentId, queryClient]); + }, [translating, contentId, queryClient, seasonNumber, seriesId]); // The refetched detail no longer reports a missing language: done. useEffect(() => { diff --git a/web/src/pages/ItemDetail/EpisodeContent.test.tsx b/web/src/pages/ItemDetail/EpisodeContent.test.tsx index 1dd706b1..a1e5cf29 100644 --- a/web/src/pages/ItemDetail/EpisodeContent.test.tsx +++ b/web/src/pages/ItemDetail/EpisodeContent.test.tsx @@ -7,6 +7,7 @@ import EpisodeContent from "./EpisodeContent"; const mocks = vi.hoisted(() => { let capturedActionBarProps: Record | null = null; + let capturedDetailHeroProps: Record | null = null; return { capturedActionBarProps: { @@ -17,6 +18,14 @@ const mocks = vi.hoisted(() => { capturedActionBarProps = value; }, }, + capturedDetailHeroProps: { + get value() { + return capturedDetailHeroProps; + }, + set value(value: Record | null) { + capturedDetailHeroProps = value; + }, + }, useSeasonDetail: vi.fn(), useSeasonEpisodes: vi.fn(), useAuth: vi.fn(), @@ -27,6 +36,7 @@ const mocks = vi.hoisted(() => { useRating: vi.fn(), useSetRating: vi.fn(), useDeleteRating: vi.fn(), + useOnViewTranslation: vi.fn(), setRatingMutate: vi.fn(), deleteRatingMutate: vi.fn(), startPlayback: vi.fn(), @@ -47,6 +57,10 @@ vi.mock("@/hooks/useCurrentProfile", () => ({ useCurrentProfile: mocks.useCurrentProfile, })); +vi.mock("@/hooks/useOnViewTranslation", () => ({ + useOnViewTranslation: mocks.useOnViewTranslation, +})); + vi.mock("@/playback/watchPlaybackContext", () => ({ useWatchPlaybackController: () => ({ startPlayback: mocks.startPlayback, @@ -78,12 +92,15 @@ vi.mock("@/components/DownloadVersionPicker", () => ({ })); vi.mock("./DetailHero", () => ({ - default: ({ context, actions }: { context?: ReactNode; actions?: ReactNode }) => ( -
- {context} - {actions} -
- ), + default: (props: { context?: ReactNode; actions?: ReactNode } & Record) => { + mocks.capturedDetailHeroProps.value = props; + return ( +
+ {props.context} + {props.actions} +
+ ); + }, })); vi.mock("./components/MetadataBadges", () => ({ @@ -203,8 +220,10 @@ function countOccurrences(markup: string, fragment: string): number { describe("EpisodeContent", () => { beforeEach(() => { mocks.capturedActionBarProps.value = null; + mocks.capturedDetailHeroProps.value = null; mocks.useAuth.mockReturnValue({ user: null }); mocks.useCurrentProfile.mockReturnValue({ profile: null }); + mocks.useOnViewTranslation.mockReturnValue({ translating: false, onTranslate: undefined }); mocks.useRefreshItemMetadata.mockReturnValue({ mutate: vi.fn(), isPending: false, @@ -263,6 +282,28 @@ describe("EpisodeContent", () => { expect(markup).toContain(">Season 99<"); }); + it("passes on-view translation controls to the hero", () => { + const onTranslate = vi.fn(); + mocks.useOnViewTranslation.mockReturnValue({ + translating: true, + onTranslate, + }); + + renderToStaticMarkup( + + + , + ); + + expect(mocks.useOnViewTranslation).toHaveBeenCalledWith( + expect.objectContaining({ content_id: "episode-1", type: "episode" }), + ); + expect(mocks.capturedDetailHeroProps.value).toMatchObject({ + overviewTranslating: true, + onTranslateOverview: onTranslate, + }); + }); + it("shows all season episodes in the carousel, not just nearby ones", () => { const allEpisodes = Array.from({ length: 10 }, (_, i) => ({ content_id: `ep-${i + 1}`, diff --git a/web/src/pages/ItemDetail/EpisodeContent.tsx b/web/src/pages/ItemDetail/EpisodeContent.tsx index 6ea7b991..598795ae 100644 --- a/web/src/pages/ItemDetail/EpisodeContent.tsx +++ b/web/src/pages/ItemDetail/EpisodeContent.tsx @@ -7,6 +7,7 @@ import { useAmbientColor } from "@/hooks/useAmbientColor"; import { useAuth } from "@/hooks/useAuth"; import { useIsActingAdmin } from "@/hooks/useIsActingAdmin"; import { useCurrentProfile } from "@/hooks/useCurrentProfile"; +import { useOnViewTranslation } from "@/hooks/useOnViewTranslation"; import { useRedetectEpisodeIntro, useRefreshItemMetadata, @@ -50,6 +51,8 @@ function formatDuration(minutes: number): string { } export default function EpisodeContent({ item }: { item: ItemDetail & { type: "episode" } }) { + const { translating: overviewTranslating, onTranslate: onTranslateOverview } = + useOnViewTranslation(item); const navigate = useNavigate(); const location = useLocation(); useAmbientColor(item.backdrop_thumbhash); @@ -270,6 +273,8 @@ export default function EpisodeContent({ item }: { item: ItemDetail & { type: "e /> } overview={item.overview} + overviewTranslating={overviewTranslating} + onTranslateOverview={onTranslateOverview} crewLine={} actions={ { let capturedActionBarProps: Record | null = null; + let capturedDetailHeroProps: Record | null = null; const capturedMediaMenuProps: Record[] = []; return { @@ -18,10 +19,19 @@ const mocks = vi.hoisted(() => { capturedActionBarProps = value; }, }, + capturedDetailHeroProps: { + get value() { + return capturedDetailHeroProps; + }, + set value(value: Record | null) { + capturedDetailHeroProps = value; + }, + }, capturedMediaMenuProps, useItemEpisodes: vi.fn(), useRefreshItemMetadata: vi.fn(), useWatchedStateMutation: vi.fn(), + useOnViewTranslation: vi.fn(), useRating: vi.fn(), useSetRating: vi.fn(), useDeleteRating: vi.fn(), @@ -53,6 +63,10 @@ vi.mock("@/hooks/useCurrentProfile", () => ({ useCurrentProfile: () => ({ profile: mocks.useAuth()?.profile ?? null }), })); +vi.mock("@/hooks/useOnViewTranslation", () => ({ + useOnViewTranslation: mocks.useOnViewTranslation, +})); + vi.mock("@/components/MediaItemMenu", () => ({ default: (props: Record) => { mocks.capturedMediaMenuProps.push(props); @@ -73,7 +87,10 @@ vi.mock("@/components/ui/skeleton", () => ({ })); vi.mock("./DetailHero", () => ({ - default: ({ actions }: { actions?: ReactNode }) =>
{actions}
, + default: (props: { actions?: ReactNode } & Record) => { + mocks.capturedDetailHeroProps.value = props; + return
{props.actions}
; + }, })); vi.mock("./components/MetadataBadges", () => ({ @@ -138,8 +155,10 @@ function makeSeasonItem( describe("SeasonContent", () => { beforeEach(() => { mocks.capturedActionBarProps.value = null; + mocks.capturedDetailHeroProps.value = null; mocks.capturedMediaMenuProps.length = 0; mocks.useAuth.mockReturnValue({ user: null }); + mocks.useOnViewTranslation.mockReturnValue({ translating: false, onTranslate: undefined }); mocks.useRefreshItemMetadata.mockReturnValue({ mutate: vi.fn(), isPending: false }); mocks.useWatchedStateMutation.mockReturnValue({ mutate: vi.fn(), isPending: false }); mocks.useItemEpisodes.mockReturnValue({ @@ -209,4 +228,26 @@ describe("SeasonContent", () => { hasPartialProgress: true, }); }); + + it("passes on-view translation controls to the hero", () => { + const onTranslate = vi.fn(); + mocks.useOnViewTranslation.mockReturnValue({ + translating: true, + onTranslate, + }); + + renderToStaticMarkup( + + + , + ); + + expect(mocks.useOnViewTranslation).toHaveBeenCalledWith( + expect.objectContaining({ content_id: "season-1", type: "season" }), + ); + expect(mocks.capturedDetailHeroProps.value).toMatchObject({ + overviewTranslating: true, + onTranslateOverview: onTranslate, + }); + }); }); diff --git a/web/src/pages/ItemDetail/SeasonContent.tsx b/web/src/pages/ItemDetail/SeasonContent.tsx index e6a1a59c..126f3579 100644 --- a/web/src/pages/ItemDetail/SeasonContent.tsx +++ b/web/src/pages/ItemDetail/SeasonContent.tsx @@ -7,6 +7,7 @@ import { useAmbientColor } from "@/hooks/useAmbientColor"; import { useAuth } from "@/hooks/useAuth"; import { useIsActingAdmin } from "@/hooks/useIsActingAdmin"; import { useCurrentProfile } from "@/hooks/useCurrentProfile"; +import { useOnViewTranslation } from "@/hooks/useOnViewTranslation"; import CastCarousel from "@/components/CastCarousel"; import CrewList from "@/components/CrewList"; import EditMetadataDialog from "@/components/EditMetadataDialog"; @@ -27,6 +28,8 @@ function seasonLabel(seasonNumber: number, title?: string) { } export default function SeasonContent({ item }: { item: ItemDetail & { type: "season" } }) { + const { translating: overviewTranslating, onTranslate: onTranslateOverview } = + useOnViewTranslation(item); const navigate = useNavigate(); useAmbientColor(item.backdrop_thumbhash); const { user } = useAuth(); @@ -102,6 +105,8 @@ export default function SeasonContent({ item }: { item: ItemDetail & { type: "se /> } overview={item.overview} + overviewTranslating={overviewTranslating} + onTranslateOverview={onTranslateOverview} actions={