fix(metadata): enable AI translation on season and episode pages (#238)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
)}
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user