* feat(nodeconfig): harden config watcher for integrated-mode use - RequestReload(): non-blocking, coalescing reload nudge that runs on the poll goroutine, so concurrent requests can never swap a stale snapshot over a newer one (unlike ForceReload from request handlers) - Skip OnChange callbacks when the reloaded config is deep-equal to the previous one, so the 60s poll doesn't fire rebuild/log callbacks on no-op reloads - Add RedisURL to BootstrapOverrides; previously a reload clobbered an env-provided Redis URL in the live config - Split reload into fetchSettings/applySettings and add unit tests Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(config): hot-reload config watcher in integrated mode Start nodeconfig.Watcher in integrated/api mode (previously only proxy/ transcode worker modes hot-reloaded). Expose the live config to the API and jellycompat routers via func-typed LiveConfig/OnConfigChange fields with nil fallbacks to the startup snapshot, and wire the admin settings update hook to RequestReload so same-process changes apply immediately even without Redis. No consumer reads the live config yet — conversions land separately. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(admin): truthful restart-required banner for settings saves The settings UI showed 'restart required' after every save regardless of the key. The backend now classifies each key via a central registry (internal/config/restart_keys.go) and PUT /admin/settings/{key} reports restart_required per key; useSettingsForm only raises the banner when a saved key actually needs a restart (and keeps it raised until restart). The registry is conservative: every currently startup-frozen key is marked restart-required; subsequent hot-reload conversions shrink it. Settings read live from the settings repo (branding, overlays, markers, download.*, ...) default to no-restart. DownloadSettings/OverlaySettings drop their hardcoded restartRequired={false} special-casing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(logging): hot-reload server.log_level and server.log_quiet Share one slog.LevelVar across the handler chain and make logfilter.Handler's quiet-prefix list an atomic pointer shared with WithAttrs/WithGroup clones (New previously returned the inner handler unwrapped when the quiet list was empty, leaving nothing to update). The integrated-mode config watcher now applies both settings live; their keys leave the restart-required registry. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(auth): hot-reload access/refresh token expiries JWTService stores expiries as atomics with a SetExpiries hook; all three instances (main API, ABS compat, jellycompat) re-apply them on config reload. Applies to newly issued tokens; outstanding tokens keep their original expiry. The JWT secret stays fixed for the process lifetime. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(playback): read transcode config live at session start The playback and stream handlers pull ffmpeg path / hwaccel / transcode dir from the live config when starting a transcode or extracting subtitles, instead of values frozen at router construction. Each session snapshots the config once so its output dir and binary stay consistent. Also fixes a real bug: playback.hw_device was parsed into the config but never wired into the integrated-mode handler, so local transcodes always ran with an empty HWDevice while transcode nodes honored it. playback.transcode_dir leaves the restart-required registry (the handler is its only consumer); ffmpeg_path/hw_accel stay restart-required until scanner/chapterthumbs/audiobook consumers convert. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(jellycompat): read compat identity settings live per request System/Auth handlers take a config provider instead of the startup snapshot, so jellyfin_compat.public_url, .server_name, and .emulated_server_version apply without restart. server_id stays restart-required (generate-once, baked into the resource mapper), as do the session-store TTLs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(scanner,metadata,mdblist): hot-reload worker pools and API key scanner.workers, matcher.workers/batch_size, metadata.cache_images, and mdblist.api_key convert to atomic fields with setters wired to the config watcher. Worker counts apply on the next scan/match cycle (the loops read them per cycle); the MDBList key applies to the next request. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(ai): hot-reload AI connection, models, toggles, and quotas The shared llm.Client holds its config behind an atomic pointer (UpdateConfig; each request snapshots once), and the subtitle/metadata AI services gain UpdateConfig plus setters on the translator (batching) and Whisper transcriber (ffmpeg path, chunk seconds). The router derives their configs from shared helpers used both at construction and in OnConfigChange callbacks, re-evaluating the chat-only-gateway transcribe guard on each reload and warning only when it newly fires. Everything on the AI Services page now applies without restart except ai.max_concurrent_jobs (fixed-capacity dispatch semaphore). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(playback): wire transcode_enabled; remove dead playback/scanner knobs playback.transcode_enabled was parsed into the config but the resolver always received a hardcoded true — the admin toggle did nothing. It now reads the live config per playback start, so disabling transcodes applies without restart. Remove settings that were wired to nothing so 'save + restart' stops pretending: playback.allow_hevc_encoding (resolver field never assigned), playback.transcode_ahead_segments and playback.segment_duration (parsed, never consumed — segment duration is per-session from the client), scanner.file_removal_grace (DeleteMissing is never called). UI fields removed and the config struct fields pruned so they don't resurrect; YAML import still tolerates the legacy keys. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
609 lines
19 KiB
Go
609 lines
19 KiB
Go
package translation
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/ai/jobrunner"
|
|
"github.com/Silo-Server/silo-server/internal/ai/llm"
|
|
aitranslate "github.com/Silo-Server/silo-server/internal/ai/translate"
|
|
"github.com/Silo-Server/silo-server/internal/subtitles"
|
|
)
|
|
|
|
// Config holds the runtime configuration for metadata translation. The shared
|
|
// endpoint connection lives in the llm client this service is wired with.
|
|
type Config struct {
|
|
Enabled bool
|
|
Configured bool // shared AI endpoint has a base URL
|
|
ChatModel string // for job provenance and idempotency
|
|
// OnView controls viewer-triggered translation: "off" | "button" | "auto".
|
|
OnView string
|
|
}
|
|
|
|
// Ready reports whether metadata translation can currently run.
|
|
func (c Config) Ready() bool { return c.Enabled && c.Configured && c.ChatModel != "" }
|
|
|
|
// OnViewMode reports the viewer-triggered translation mode ("off" when the
|
|
// feature itself is not ready).
|
|
func (c Config) OnViewMode() string {
|
|
if !c.Ready() || c.OnView == "" {
|
|
return "off"
|
|
}
|
|
return c.OnView
|
|
}
|
|
|
|
// onViewFailureCooldown suppresses re-enqueueing after a failed job for the
|
|
// same target+language, so a broken endpoint is not retried on every page
|
|
// view. Admin-triggered jobs are unaffected.
|
|
const onViewFailureCooldown = 15 * time.Minute
|
|
|
|
// Service owns the metadata translation job semantics: enqueue validation,
|
|
// field collection with skip-if-filled, batched translation, and
|
|
// provenance-aware persistence. Lifecycle mechanics are delegated to the
|
|
// shared jobrunner.
|
|
type Service struct {
|
|
// cfg is behind an atomic pointer so admin settings changes apply to
|
|
// subsequent enqueues/view checks without rebuilding the service.
|
|
cfg atomic.Pointer[Config]
|
|
repo JobRepository
|
|
content ContentReader
|
|
locs LocalizationStore
|
|
chat aitranslate.ChatFn
|
|
runner *jobrunner.Runner
|
|
logger *slog.Logger
|
|
}
|
|
|
|
// UpdateConfig swaps the service config. Safe for concurrent use; running
|
|
// jobs finish with the config they started with.
|
|
func (s *Service) UpdateConfig(cfg Config) {
|
|
s.cfg.Store(&cfg)
|
|
}
|
|
|
|
// config returns a snapshot of the current service config.
|
|
func (s *Service) config() Config {
|
|
return *s.cfg.Load()
|
|
}
|
|
|
|
// NewService wires a metadata translation service. sem is the dispatch
|
|
// semaphore shared with the other AI job services.
|
|
func NewService(
|
|
appCtx context.Context,
|
|
cfg Config,
|
|
repo JobRepository,
|
|
content ContentReader,
|
|
locs LocalizationStore,
|
|
chat aitranslate.ChatFn,
|
|
sem chan struct{},
|
|
logger *slog.Logger,
|
|
) *Service {
|
|
if logger == nil {
|
|
logger = slog.Default()
|
|
}
|
|
s := &Service{
|
|
repo: repo,
|
|
content: content,
|
|
locs: locs,
|
|
chat: chat,
|
|
runner: jobrunner.New(appCtx, sem, repo, "metadata translation", logger),
|
|
logger: logger,
|
|
}
|
|
s.UpdateConfig(cfg)
|
|
return s
|
|
}
|
|
|
|
// Enabled reports whether metadata translation can currently run.
|
|
func (s *Service) Enabled() bool { return s.config().Ready() }
|
|
|
|
// Recover clears jobs orphaned by a crashed worker and starts a background
|
|
// reaper that keeps doing so. Call once at startup.
|
|
func (s *Service) Recover() { s.runner.Recover() }
|
|
|
|
// Enqueue validates and queues a job, returning immediately. If an identical
|
|
// job is already pending or running, that job is returned instead of a new one.
|
|
func (s *Service) Enqueue(ctx context.Context, req JobRequest) (*Job, error) {
|
|
if !s.config().Ready() {
|
|
return nil, ErrNotConfigured
|
|
}
|
|
if req.TargetKind == "" {
|
|
req.TargetKind = TargetItem
|
|
}
|
|
switch req.TargetKind {
|
|
case TargetItem, TargetSeason, TargetEpisode:
|
|
default:
|
|
return nil, fmt.Errorf("%w: unsupported target kind %q", ErrInvalidRequest, req.TargetKind)
|
|
}
|
|
if strings.TrimSpace(req.ContentID) == "" {
|
|
return nil, fmt.Errorf("%w: content id is required", ErrInvalidRequest)
|
|
}
|
|
target, err := subtitles.NormalizeLanguageCode(req.TargetLanguage)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: invalid target language %q", ErrInvalidRequest, req.TargetLanguage)
|
|
}
|
|
req.TargetLanguage = target
|
|
|
|
key := idempotencyKey(req.TargetKind, req.ContentID, req.TargetLanguage, s.config().ChatModel)
|
|
if existing, err := s.repo.GetActiveJobByIdempotencyKey(ctx, key); err != nil {
|
|
return nil, err
|
|
} else if existing != nil {
|
|
return existing, nil
|
|
}
|
|
|
|
job := &Job{
|
|
TargetKind: req.TargetKind,
|
|
ContentID: req.ContentID,
|
|
IncludeChildren: req.IncludeChildren,
|
|
TargetLanguage: req.TargetLanguage,
|
|
Engine: "openai",
|
|
Model: s.config().ChatModel,
|
|
Status: jobrunner.StatusPending,
|
|
ProgressMessage: "Queued",
|
|
Force: req.Force,
|
|
IdempotencyKey: key,
|
|
RequestedBy: req.RequestedBy,
|
|
}
|
|
if err := s.repo.InsertJob(ctx, job); err != nil {
|
|
// A racing duplicate trips the partial unique index; return the winner.
|
|
if existing, lookupErr := s.repo.GetActiveJobByIdempotencyKey(ctx, key); lookupErr == nil && existing != nil {
|
|
return existing, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
s.dispatch(*job)
|
|
return job, nil
|
|
}
|
|
|
|
// AutoEnqueue is the metadata-refresh fallback hook: when the library opted
|
|
// in and the providers left translatable fields without a localization for
|
|
// language, it queues a non-force item job covering the children. Cheap on
|
|
// repeat refreshes — a missing-field count guards the enqueue, and the run
|
|
// loop re-checks per field, so fully translated items never reach the model.
|
|
// Errors are logged, never returned: a refresh must not fail on this.
|
|
func (s *Service) AutoEnqueue(ctx context.Context, itemContentID, language string) {
|
|
if !s.config().Ready() {
|
|
return
|
|
}
|
|
target, err := subtitles.NormalizeLanguageCode(language)
|
|
if err != nil {
|
|
return
|
|
}
|
|
missing, err := s.content.CountMissingFields(ctx, itemContentID, target)
|
|
if err != nil {
|
|
s.logger.Warn("metadata translation: missing-field count failed",
|
|
"content_id", itemContentID, "language", target, "error", err)
|
|
return
|
|
}
|
|
if missing == 0 {
|
|
return
|
|
}
|
|
if _, err := s.Enqueue(ctx, JobRequest{
|
|
TargetKind: TargetItem,
|
|
ContentID: itemContentID,
|
|
TargetLanguage: target,
|
|
IncludeChildren: true,
|
|
}); err != nil {
|
|
s.logger.Warn("metadata translation: auto-enqueue failed",
|
|
"content_id", itemContentID, "language", target, "error", err)
|
|
}
|
|
}
|
|
|
|
// OnViewMode exposes the viewer-triggered translation mode for status probes.
|
|
func (s *Service) OnViewMode() string { return s.config().OnViewMode() }
|
|
|
|
// RequestOnView is the viewer-triggered enqueue path (detail-page button or
|
|
// auto-on-view). On top of the normal pipeline it suppresses retries for a
|
|
// 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) {
|
|
if s.config().OnViewMode() == "off" {
|
|
return nil, ErrNotConfigured
|
|
}
|
|
target, err := subtitles.NormalizeLanguageCode(targetLanguage)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: invalid target language %q", ErrInvalidRequest, targetLanguage)
|
|
}
|
|
|
|
jobs, err := s.repo.ListJobsByContent(ctx, contentID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, job := range jobs {
|
|
if job.TargetLanguage != target {
|
|
continue
|
|
}
|
|
if job.Status == jobrunner.StatusFailed && time.Since(job.UpdatedAt) < onViewFailureCooldown {
|
|
cooled := job
|
|
return &cooled, nil
|
|
}
|
|
break // most recent job for this language decides; older history is irrelevant
|
|
}
|
|
|
|
return s.Enqueue(ctx, JobRequest{
|
|
TargetKind: TargetItem,
|
|
ContentID: contentID,
|
|
TargetLanguage: target,
|
|
IncludeChildren: true,
|
|
RequestedBy: requestedBy,
|
|
})
|
|
}
|
|
|
|
// GetJob returns a job by ID.
|
|
func (s *Service) GetJob(ctx context.Context, id int64) (*Job, error) {
|
|
job, err := s.repo.GetJob(ctx, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if job == nil {
|
|
return nil, ErrJobNotFound
|
|
}
|
|
return job, nil
|
|
}
|
|
|
|
// ListJobs returns recent jobs for a content ID.
|
|
func (s *Service) ListJobs(ctx context.Context, contentID string) ([]Job, error) {
|
|
return s.repo.ListJobsByContent(ctx, contentID)
|
|
}
|
|
|
|
// Cancel requests cancellation of a job.
|
|
func (s *Service) Cancel(ctx context.Context, id int64) error {
|
|
job, err := s.repo.GetJob(ctx, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if job == nil {
|
|
return ErrJobNotFound
|
|
}
|
|
if s.runner.Cancel(id) {
|
|
return nil
|
|
}
|
|
if !job.Status.Terminal() {
|
|
return s.repo.FailJob(ctx, id, jobrunner.StatusCancelled, "cancelled")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) dispatch(job Job) {
|
|
s.runner.Dispatch(job.ID, func(ctx context.Context) {
|
|
s.run(ctx, &job)
|
|
}, func(ctx context.Context) {
|
|
_ = s.repo.FailJob(ctx, job.ID, jobrunner.StatusCancelled, "cancelled before start")
|
|
})
|
|
}
|
|
|
|
// field is one translatable unit of a job.
|
|
type field struct {
|
|
kind TargetKind
|
|
contentID string
|
|
isTagline bool
|
|
text string
|
|
}
|
|
|
|
func (f field) segmentID() string {
|
|
if f.kind == TargetItem && f.isTagline {
|
|
return "item:tagline:" + f.contentID
|
|
}
|
|
return string(f.kind) + ":overview:" + f.contentID
|
|
}
|
|
|
|
func (s *Service) run(ctx context.Context, job *Job) {
|
|
if err := s.repo.UpdateProgress(ctx, job.ID, jobrunner.StatusRunning, 0, "Loading content", 0, 0); err != nil {
|
|
s.logger.Warn("failed to mark metadata translation job running", "job", job.ID, "error", err)
|
|
}
|
|
|
|
fields, meta, err := s.collectFields(ctx, job)
|
|
if err != nil {
|
|
s.finishWithError(ctx, job, err)
|
|
return
|
|
}
|
|
if len(fields) == 0 {
|
|
if err := s.repo.CompleteJob(context.WithoutCancel(ctx), job.ID, "Nothing to translate", 0, 0); err != nil {
|
|
s.logger.Warn("failed to complete metadata translation job", "job", job.ID, "error", err)
|
|
}
|
|
return
|
|
}
|
|
|
|
total := len(fields)
|
|
if err := s.repo.UpdateProgress(ctx, job.ID, jobrunner.StatusRunning, 0.05, "Translating", 0, total); err != nil {
|
|
s.logger.Warn("failed to update metadata translation job", "job", job.ID, "error", err)
|
|
}
|
|
|
|
segments := make([]aitranslate.Segment, total)
|
|
byID := make(map[string]field, total)
|
|
for i, f := range fields {
|
|
segments[i] = aitranslate.Segment{ID: f.segmentID(), Text: f.text}
|
|
byID[f.segmentID()] = f
|
|
}
|
|
|
|
srcName := aitranslate.LanguageDisplayName(meta.srcLanguage)
|
|
tgtName := aitranslate.LanguageDisplayName(job.TargetLanguage)
|
|
|
|
// Persist per batch so a cancellation keeps completed fields. A persist
|
|
// failure cancels the remaining batches via persistCtx instead of burning
|
|
// model calls whose output cannot be stored.
|
|
persistCtx, cancelPersist := context.WithCancel(ctx)
|
|
defer cancelPersist()
|
|
var persistMu sync.Mutex
|
|
var persistErr error
|
|
done := 0
|
|
|
|
_, translateErr := aitranslate.Translate(persistCtx, s.chat, aitranslate.Request{
|
|
Segments: segments,
|
|
SystemPrompt: systemPrompt(srcName, tgtName, meta.title, meta.year),
|
|
TargetName: tgtName,
|
|
EntryNoun: "descriptions",
|
|
BatchSize: metadataBatchSize,
|
|
ContextNeighbors: 0,
|
|
}, func(batch []aitranslate.Segment, batchDone, batchTotal int) {
|
|
// Finished translations should not be thrown away by a late cancel.
|
|
storeCtx := context.WithoutCancel(ctx)
|
|
for _, seg := range batch {
|
|
f, ok := byID[seg.ID]
|
|
if !ok {
|
|
continue
|
|
}
|
|
if err := s.persistField(storeCtx, job, f, seg.Text); err != nil {
|
|
persistMu.Lock()
|
|
if persistErr == nil {
|
|
persistErr = err
|
|
}
|
|
persistMu.Unlock()
|
|
cancelPersist()
|
|
return
|
|
}
|
|
done++
|
|
}
|
|
progress := 0.05 + 0.9*float64(batchDone)/float64(batchTotal)
|
|
_ = s.repo.UpdateProgress(storeCtx, job.ID, jobrunner.StatusRunning, progress, "Translating", done, total)
|
|
})
|
|
|
|
persistMu.Lock()
|
|
failure := persistErr
|
|
persistMu.Unlock()
|
|
if failure == nil && translateErr != nil {
|
|
failure = translateErr
|
|
}
|
|
if failure != nil {
|
|
s.finishWithError(ctx, job, failure)
|
|
return
|
|
}
|
|
|
|
if err := s.repo.CompleteJob(context.WithoutCancel(ctx), job.ID, "", done, total); err != nil {
|
|
s.logger.Warn("failed to complete metadata translation job", "job", job.ID, "error", err)
|
|
}
|
|
}
|
|
|
|
func (s *Service) persistField(ctx context.Context, job *Job, f field, text string) error {
|
|
text = strings.TrimSpace(text)
|
|
if text == "" {
|
|
return fmt.Errorf("model returned an empty translation for %s", f.segmentID())
|
|
}
|
|
switch f.kind {
|
|
case TargetItem:
|
|
if f.isTagline {
|
|
return s.locs.UpsertItemAI(ctx, f.contentID, job.TargetLanguage, nil, &text, job.Force)
|
|
}
|
|
return s.locs.UpsertItemAI(ctx, f.contentID, job.TargetLanguage, &text, nil, job.Force)
|
|
case TargetSeason:
|
|
return s.locs.UpsertSeasonAI(ctx, f.contentID, job.TargetLanguage, text, job.Force)
|
|
case TargetEpisode:
|
|
return s.locs.UpsertEpisodeAI(ctx, f.contentID, job.TargetLanguage, text, job.Force)
|
|
default:
|
|
return fmt.Errorf("unknown field kind %q", f.kind)
|
|
}
|
|
}
|
|
|
|
// jobMeta carries the prompt-grounding context for a job.
|
|
type jobMeta struct {
|
|
title string
|
|
year int
|
|
srcLanguage string
|
|
}
|
|
|
|
// collectFields expands the job target into translatable fields, dropping any
|
|
// field whose localized value is already filled (unless force, which still
|
|
// respects manual provenance).
|
|
func (s *Service) collectFields(ctx context.Context, job *Job) ([]field, jobMeta, error) {
|
|
var meta jobMeta
|
|
|
|
switch job.TargetKind {
|
|
case TargetItem:
|
|
item, err := s.content.ItemText(ctx, job.ContentID)
|
|
if err != nil {
|
|
return nil, meta, fmt.Errorf("load item: %w", err)
|
|
}
|
|
if item == nil {
|
|
return nil, meta, fmt.Errorf("%w: item not found", ErrInvalidRequest)
|
|
}
|
|
meta = jobMeta{title: item.Title, year: item.Year, srcLanguage: item.DefaultLanguage}
|
|
job.SourceLanguage = item.DefaultLanguage
|
|
|
|
var fields []field
|
|
itemFields, err := s.itemFields(ctx, job, item)
|
|
if err != nil {
|
|
return nil, meta, err
|
|
}
|
|
fields = append(fields, itemFields...)
|
|
|
|
if job.IncludeChildren {
|
|
childFields, err := s.childFields(ctx, job, job.ContentID)
|
|
if err != nil {
|
|
return nil, meta, err
|
|
}
|
|
fields = append(fields, childFields...)
|
|
}
|
|
return fields, meta, nil
|
|
|
|
case TargetSeason:
|
|
season, seriesID, err := s.content.SeasonByID(ctx, job.ContentID)
|
|
if err != nil {
|
|
return nil, meta, fmt.Errorf("load season: %w", err)
|
|
}
|
|
if season == nil {
|
|
return nil, meta, fmt.Errorf("%w: season not found", ErrInvalidRequest)
|
|
}
|
|
meta, err = s.parentMeta(ctx, job, seriesID)
|
|
if err != nil {
|
|
return nil, meta, err
|
|
}
|
|
fields, err := s.filterSeasons(ctx, job, []ChildText{*season})
|
|
return fields, meta, err
|
|
|
|
case TargetEpisode:
|
|
episode, seriesID, err := s.content.EpisodeByID(ctx, job.ContentID)
|
|
if err != nil {
|
|
return nil, meta, fmt.Errorf("load episode: %w", err)
|
|
}
|
|
if episode == nil {
|
|
return nil, meta, fmt.Errorf("%w: episode not found", ErrInvalidRequest)
|
|
}
|
|
meta, err = s.parentMeta(ctx, job, seriesID)
|
|
if err != nil {
|
|
return nil, meta, err
|
|
}
|
|
fields, err := s.filterEpisodes(ctx, job, []ChildText{*episode})
|
|
return fields, meta, err
|
|
|
|
default:
|
|
return nil, meta, fmt.Errorf("%w: unsupported target kind %q", ErrInvalidRequest, job.TargetKind)
|
|
}
|
|
}
|
|
|
|
func (s *Service) parentMeta(ctx context.Context, job *Job, seriesID string) (jobMeta, error) {
|
|
var meta jobMeta
|
|
item, err := s.content.ItemText(ctx, seriesID)
|
|
if err != nil {
|
|
return meta, fmt.Errorf("load parent series: %w", err)
|
|
}
|
|
if item != nil {
|
|
meta = jobMeta{title: item.Title, year: item.Year, srcLanguage: item.DefaultLanguage}
|
|
job.SourceLanguage = item.DefaultLanguage
|
|
}
|
|
return meta, nil
|
|
}
|
|
|
|
// translatableField reports whether a field with base text and an existing
|
|
// localized value/source should be translated. Empty base text never
|
|
// translates; filled localizations are skipped unless force; manual values
|
|
// are skipped even with force (the SQL layer guards them too — skipping here
|
|
// saves the model call).
|
|
func translatableField(baseText, locValue, locSource string, force bool) bool {
|
|
if strings.TrimSpace(baseText) == "" {
|
|
return false
|
|
}
|
|
if locSource == "manual" {
|
|
return false
|
|
}
|
|
if locValue != "" && !force {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (s *Service) itemFields(ctx context.Context, job *Job, item *ItemText) ([]field, error) {
|
|
loc, err := s.locs.ItemLocalization(ctx, item.ContentID, job.TargetLanguage)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("load item localization: %w", err)
|
|
}
|
|
var locOverview, locOverviewSrc, locTagline, locTaglineSrc string
|
|
if loc != nil {
|
|
locOverview, locOverviewSrc = loc.Overview, loc.OverviewSource
|
|
locTagline, locTaglineSrc = loc.Tagline, loc.TaglineSource
|
|
}
|
|
|
|
var fields []field
|
|
if translatableField(item.Overview, locOverview, locOverviewSrc, job.Force) {
|
|
fields = append(fields, field{kind: TargetItem, contentID: item.ContentID, text: item.Overview})
|
|
}
|
|
if translatableField(item.Tagline, locTagline, locTaglineSrc, job.Force) {
|
|
fields = append(fields, field{kind: TargetItem, contentID: item.ContentID, isTagline: true, text: item.Tagline})
|
|
}
|
|
return fields, nil
|
|
}
|
|
|
|
func (s *Service) childFields(ctx context.Context, job *Job, seriesID string) ([]field, error) {
|
|
seasons, err := s.content.SeasonTexts(ctx, seriesID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("load seasons: %w", err)
|
|
}
|
|
fields, err := s.filterSeasons(ctx, job, seasons)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
episodes, err := s.content.EpisodeTexts(ctx, seriesID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("load episodes: %w", err)
|
|
}
|
|
episodeFields, err := s.filterEpisodes(ctx, job, episodes)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return append(fields, episodeFields...), nil
|
|
}
|
|
|
|
func (s *Service) filterSeasons(ctx context.Context, job *Job, seasons []ChildText) ([]field, error) {
|
|
ids := make([]string, 0, len(seasons))
|
|
for _, season := range seasons {
|
|
ids = append(ids, season.ContentID)
|
|
}
|
|
locs, err := s.locs.SeasonLocalizations(ctx, ids, job.TargetLanguage)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("load season localizations: %w", err)
|
|
}
|
|
var fields []field
|
|
for _, season := range seasons {
|
|
var locOverview, locSource string
|
|
if loc := locs[season.ContentID]; loc != nil {
|
|
locOverview, locSource = loc.Overview, loc.OverviewSource
|
|
}
|
|
if translatableField(season.Overview, locOverview, locSource, job.Force) {
|
|
fields = append(fields, field{kind: TargetSeason, contentID: season.ContentID, text: season.Overview})
|
|
}
|
|
}
|
|
return fields, nil
|
|
}
|
|
|
|
func (s *Service) filterEpisodes(ctx context.Context, job *Job, episodes []ChildText) ([]field, error) {
|
|
ids := make([]string, 0, len(episodes))
|
|
for _, episode := range episodes {
|
|
ids = append(ids, episode.ContentID)
|
|
}
|
|
locs, err := s.locs.EpisodeLocalizations(ctx, ids, job.TargetLanguage)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("load episode localizations: %w", err)
|
|
}
|
|
var fields []field
|
|
for _, episode := range episodes {
|
|
var locOverview, locSource string
|
|
if loc := locs[episode.ContentID]; loc != nil {
|
|
locOverview, locSource = loc.Overview, loc.OverviewSource
|
|
}
|
|
if translatableField(episode.Overview, locOverview, locSource, job.Force) {
|
|
fields = append(fields, field{kind: TargetEpisode, contentID: episode.ContentID, text: episode.Overview})
|
|
}
|
|
}
|
|
return fields, nil
|
|
}
|
|
|
|
func (s *Service) finishWithError(ctx context.Context, job *Job, err error) {
|
|
status := jobrunner.StatusFailed
|
|
msg := llm.Truncate(err.Error(), 500)
|
|
if errors.Is(err, context.Canceled) || errors.Is(ctx.Err(), context.Canceled) {
|
|
status = jobrunner.StatusCancelled
|
|
msg = "cancelled"
|
|
}
|
|
if dbErr := s.repo.FailJob(context.WithoutCancel(ctx), job.ID, status, msg); dbErr != nil {
|
|
s.logger.Warn("failed to record metadata translation job failure", "job", job.ID, "error", dbErr)
|
|
}
|
|
if status == jobrunner.StatusFailed {
|
|
s.logger.Warn("metadata translation job failed", "job", job.ID, "content_id", job.ContentID, "error", err)
|
|
}
|
|
}
|