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) && (
- 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.