fix(metadata): enable AI translation on season and episode pages (#238)

This commit is contained in:
Quick
2026-06-27 21:44:28 -04:00
committed by GitHub
parent a0fbe2cdda
commit 9cea2bb4f0
13 changed files with 466 additions and 64 deletions
+93 -6
View File
@@ -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,
+124
View File
@@ -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)
}
}
+2
View File
@@ -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 {
+75 -37
View File
@@ -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")
+13 -3
View File
@@ -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,
})
}
+41 -4
View File
@@ -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)
}
}
+1 -1
View File
@@ -340,7 +340,7 @@ export default function EditMetadataDialog({ item, open, onOpenChange }: EditMet
/>
</FieldRow>
{(item.type === "movie" || item.type === "series") && (
{["movie", "series", "season", "episode"].includes(item.type) && (
<MetadataTranslatePanel item={item} />
)}
+10 -5
View File
@@ -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 }) {
<span className="text-sm font-medium">Translate with AI</span>
</div>
<p className="text-muted-foreground text-xs">
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.
</p>
<div className="flex flex-wrap items-end gap-3">
<div className="space-y-1">
+8 -1
View File
@@ -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", <libraryId|"default">]).
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(() => {
@@ -7,6 +7,7 @@ import EpisodeContent from "./EpisodeContent";
const mocks = vi.hoisted(() => {
let capturedActionBarProps: Record<string, unknown> | null = null;
let capturedDetailHeroProps: Record<string, unknown> | null = null;
return {
capturedActionBarProps: {
@@ -17,6 +18,14 @@ const mocks = vi.hoisted(() => {
capturedActionBarProps = value;
},
},
capturedDetailHeroProps: {
get value() {
return capturedDetailHeroProps;
},
set value(value: Record<string, unknown> | 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 }) => (
<div>
{context}
{actions}
</div>
),
default: (props: { context?: ReactNode; actions?: ReactNode } & Record<string, unknown>) => {
mocks.capturedDetailHeroProps.value = props;
return (
<div>
{props.context}
{props.actions}
</div>
);
},
}));
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(
<MemoryRouter initialEntries={["/item/episode-1"]}>
<EpisodeContent item={makeEpisodeItem({ pending_translation_language: "fr" })} />
</MemoryRouter>,
);
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}`,
@@ -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={<HeroCrewLine crew={item.crew ?? []} />}
actions={
<ActionBar
@@ -7,6 +7,7 @@ import SeasonContent from "./SeasonContent";
const mocks = vi.hoisted(() => {
let capturedActionBarProps: Record<string, unknown> | null = null;
let capturedDetailHeroProps: Record<string, unknown> | null = null;
const capturedMediaMenuProps: Record<string, unknown>[] = [];
return {
@@ -18,10 +19,19 @@ const mocks = vi.hoisted(() => {
capturedActionBarProps = value;
},
},
capturedDetailHeroProps: {
get value() {
return capturedDetailHeroProps;
},
set value(value: Record<string, unknown> | 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<string, unknown>) => {
mocks.capturedMediaMenuProps.push(props);
@@ -73,7 +87,10 @@ vi.mock("@/components/ui/skeleton", () => ({
}));
vi.mock("./DetailHero", () => ({
default: ({ actions }: { actions?: ReactNode }) => <div>{actions}</div>,
default: (props: { actions?: ReactNode } & Record<string, unknown>) => {
mocks.capturedDetailHeroProps.value = props;
return <div>{props.actions}</div>;
},
}));
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(
<MemoryRouter initialEntries={["/item/season-1"]}>
<SeasonContent item={makeSeasonItem({ pending_translation_language: "fr" })} />
</MemoryRouter>,
);
expect(mocks.useOnViewTranslation).toHaveBeenCalledWith(
expect.objectContaining({ content_id: "season-1", type: "season" }),
);
expect(mocks.capturedDetailHeroProps.value).toMatchObject({
overviewTranslating: true,
onTranslateOverview: onTranslate,
});
});
});
@@ -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={
<ActionBar
contentId={item.content_id}