* docs: design + plan for shared AI core, metadata translation, Whisper ASR Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(ai): shared LLM client, segment translator, and job runner packages internal/ai/llm: OpenAI-compatible chat client moved out of subtitles/ai, plus /v1/audio/transcriptions (verbose_json) for the ASR work; one shared retry/backoff loop for both. internal/ai/translate: the batched indexed-JSON translation protocol generalized to text segments. internal/ai/jobrunner: dispatch/heartbeat/reaper/cancel lifecycle extracted behind a minimal store interface, with a semaphore shareable across job services. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(subtitles): consume shared AI core LLMTranslator becomes a thin cue<->segment adapter over aitranslate; the service delegates dispatch/heartbeat/reaper/cancel to jobrunner; the local OpenAI client is gone in favor of internal/ai/llm. Behavior (prompts, wire protocol, job rows, recovery semantics) is unchanged. NewService now takes the dispatch semaphore so all AI job services can share one bound. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(config): shared ai.* settings, metadata translation job table, localization provenance columns ai.* connection keys (chat + optional separate ASR endpoint) load with a fallback to the legacy subtitle_ai.* rows — those are never renamed in SQL because encrypted values are GCM-bound to their setting key. New toggles: subtitle_ai.transcribe_enabled, metadata_ai.enabled. Migration adds metadata_translation_jobs, per-field provenance (provider|ai|manual) on the localization tables, and media_folders.auto_translate_metadata. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(catalog): localization field provenance with provider/ai/manual precedence Provider upserts keep manual values and never blank a field with an empty incoming value; new UpsertAITranslation/UpsertAIOverview methods write AI fields only over empty or ai-sourced values (force adds provider, never manual) — all enforced in single-statement SQL. Serving now merges only non-empty localized fields onto the base item, since localization rows are legitimately partial (AI rows carry no titles/artwork). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(metadata): AI translation service, refresh auto-fallback, and admin API internal/metadata/translation: job service over the shared AI core that expands an item to its season/episode overviews, skips already-localized fields (zero model calls on repeat runs), batches paragraphs through the generic translator, and persists per batch with provenance-aware upserts. MetadataService gains an AutoTranslator seam invoked after each refresh for libraries with auto_translate_metadata. Admin endpoints under the metadata curation guard: enqueue, list (poll), cancel; plus a status probe. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(subtitles): Whisper ASR transcribe and transcribe_translate jobs New WhisperTranscriber: one ffmpeg pass extracts the audio track to 10-min 16kHz mono WAV chunks (temp dir cleaned on every exit path), each chunk goes to the OpenAI-compatible /v1/audio/transcriptions endpoint (verbose_json, per-request timeout sized to 3x chunk duration), segment timestamps are offset and built into wrapped cues. Chunks process playhead-first and stream live to the requesting session. The transcript is stored as an ordinary downloaded subtitle (provider 'transcribed'); transcribe_translate chains the existing translator and stores the translated track as the job result. Enqueue accepts an optional kind; status reports transcribe_enabled. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(web): AI services settings, metadata translate action, library auto-translate, generate-from-audio New AI Services admin page hosts the shared endpoint config (reads fall back to legacy subtitle_ai.* values, writes target ai.*) and the three feature toggles; the AI card moves out of Subtitles settings. The metadata editor gains a Translate-with-AI panel with job polling and force/re-translate. The library form gains the auto-translate toggle (threaded through the libraries API). The player translate modal gains a From-audio mode that lists audio tracks and submits transcribe / transcribe_translate jobs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * style: gofmt import grouping in router and translation tests Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(catalog): per-profile metadata language and viewer-triggered description translation user_profiles.preferred_metadata_language threads through the access scope into catalog serving: presentation language now resolves explicit param -> profile preference -> library metadata language (native API and jellycompat). ItemDetail gains pending_translation_language when the viewer's language is missing a localized overview. New metadata_ai.on_view setting (off|button| auto) gates POST /items/{id}/translate-description: any profile with item access may request its language, with in-flight dedup and a 15-minute failure cooldown so page views never hammer a broken endpoint. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(web): on-view description translation with per-profile metadata language Profile playback settings gain a Metadata language picker (library default inherit). Detail pages: when the server reports pending_translation_language and metadata_ai.on_view is 'auto', the description translates on view with a pulse animation until the refetched detail comes back localized (45s timeout); in 'button' mode a small Translate chip triggers the same flow. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(web): expose metadata_ai.on_view in AI Services settings The on-view translation mode had no UI control, so it could only ever be 'off' — viewers got neither the auto translation nor the fallback button. Adds the off/button/auto selector to the Features card, and the config loader now warns and falls back to 'off' on a bad row instead of refusing to start. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ai): clear configuration hint when the transcription endpoint is chat-only A blank Transcription base URL falls back to the chat endpoint; chat-only gateways reject the multipart upload with an opaque 400 that reads like a pipeline bug. 400/404/405 transcription failures now carry a hint to set a Whisper-compatible endpoint in AI Services. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(subtitles): wrap ASR cue text by rune count, not bytes Arabic/Cyrillic/Greek text is 2+ bytes per character in UTF-8, so byte-based wrapping broke lines at roughly half the intended visual width. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(web): steer transcription base URL hint away from chat-only gateways Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(ai): block chat-only gateways for transcription, add endpoint presets llm.IsChatOnlyGateway (OpenRouter et al — no timestamped transcription API) is enforced in three layers: the settings API rejects ai.asr_base_url values pointing at one, the router disables ASR with a warning when the blank-URL fallback would land on one, and llm.Transcribe refuses outright. The AI Services page gains one-click transcription presets (Groq turbo/accurate, OpenAI, self-hosted speaches) plus the mirrored client-side check, and the settings API now also validates metadata_ai.on_view. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(subtitles): tighten ASR subtitle sync Three systematic timing-error sources addressed: cue offsets now use the segment muxer's exact per-chunk start times (segment_list CSV) instead of assuming index*chunk_seconds; the audio stream's start delay relative to the container timeline (common in TS remuxes) is probed via ffprobe and added to every cue; and the chunk length is now operator-tunable via subtitle_ai.asr_chunk_seconds (60-600s, default 600) since shorter chunks bound Whisper's within-chunk timestamp drift. Playhead-first ordering now pivots on real chunk starts, and a beyond-end playhead starts at the final chunk instead of restarting from zero. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ai): tolerate base URLs that already include the /v1 segment Providers like DeepInfra expose their OpenAI-compatible API under a base that contains the version segment (api.deepinfra.com/v1/openai); always appending /v1/... mangled those. endpointURL now appends bare paths when the base already carries /v1. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(web): prefer self-hosted transcription in presets and hints Preset order becomes self-hosted (recommended) -> Groq turbo -> Groq large-v3 -> OpenAI, and the settings hint plus the job-error hint lead with the self-hosted option. The self-hosted preset now fills the turbo CT2 model to match the recommended speaches setup. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(subtitles): request VAD and word timestamps for ASR cue accuracy Without vad_filter, faster-whisper servers report wall-to-wall segment times: cues linger on screen through silence (verified up to 91s) and paragraph-length segments become single 400+ char cues. Request vad_filter=true (skipped for hosted providers that reject non-OpenAI fields and run VAD server-side) plus timestamp_granularities word+segment, and rebuild cues from word timings: split at speech pauses, sentence ends, text capacity, and a 7s max duration; cap word-less segments instead of trusting their reported end; stretch sub-second cues to a readable minimum. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
453 lines
17 KiB
Go
453 lines
17 KiB
Go
package catalog
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/models"
|
|
)
|
|
|
|
// The localization tables carry per-field provenance (provider | ai | manual)
|
|
// on their AI-writable fields (overview, tagline) so writes from different
|
|
// origins can never regress quality: manual beats provider beats ai. The
|
|
// precedence is enforced here, in single-statement upserts, so concurrent
|
|
// writers (a metadata refresh racing an AI translation job) cannot interleave
|
|
// a read-modify-write.
|
|
|
|
type MediaItemLocalizationRepository struct {
|
|
pool *pgxpool.Pool
|
|
}
|
|
|
|
func NewMediaItemLocalizationRepository(pool *pgxpool.Pool) *MediaItemLocalizationRepository {
|
|
return &MediaItemLocalizationRepository{pool: pool}
|
|
}
|
|
|
|
// Upsert writes a provider localization. Title/sort-title/artwork fields are
|
|
// taken wholesale (provider data is authoritative for them); overview and
|
|
// tagline respect provenance — a manual value is never overwritten, and an
|
|
// empty incoming value never blanks an existing one (so a provider with no
|
|
// translated overview does not erase an AI or manual translation).
|
|
func (r *MediaItemLocalizationRepository) Upsert(ctx context.Context, loc *models.MediaItemLocalization) error {
|
|
if loc == nil || loc.ContentID == "" || loc.Language == "" {
|
|
return fmt.Errorf("invalid media item localization")
|
|
}
|
|
|
|
_, err := r.pool.Exec(ctx, `
|
|
INSERT INTO media_item_localizations (
|
|
content_id, language, title, sort_title, overview, tagline,
|
|
poster_path, poster_thumbhash, backdrop_path, backdrop_thumbhash, logo_path
|
|
) VALUES (
|
|
$1, $2, $3, $4, $5, $6,
|
|
$7, $8, $9, $10, $11
|
|
)
|
|
ON CONFLICT (content_id, language) DO UPDATE SET
|
|
title = EXCLUDED.title,
|
|
sort_title = EXCLUDED.sort_title,
|
|
overview = CASE
|
|
WHEN media_item_localizations.overview_source = 'manual' OR EXCLUDED.overview = ''
|
|
THEN media_item_localizations.overview
|
|
ELSE EXCLUDED.overview END,
|
|
overview_source = CASE
|
|
WHEN media_item_localizations.overview_source = 'manual' OR EXCLUDED.overview = ''
|
|
THEN media_item_localizations.overview_source
|
|
ELSE 'provider' END,
|
|
tagline = CASE
|
|
WHEN media_item_localizations.tagline_source = 'manual' OR EXCLUDED.tagline = ''
|
|
THEN media_item_localizations.tagline
|
|
ELSE EXCLUDED.tagline END,
|
|
tagline_source = CASE
|
|
WHEN media_item_localizations.tagline_source = 'manual' OR EXCLUDED.tagline = ''
|
|
THEN media_item_localizations.tagline_source
|
|
ELSE 'provider' END,
|
|
poster_path = EXCLUDED.poster_path,
|
|
poster_thumbhash = EXCLUDED.poster_thumbhash,
|
|
backdrop_path = EXCLUDED.backdrop_path,
|
|
backdrop_thumbhash = EXCLUDED.backdrop_thumbhash,
|
|
logo_path = EXCLUDED.logo_path,
|
|
updated_at = NOW()
|
|
`, loc.ContentID, loc.Language, loc.Title, loc.SortTitle, loc.Overview, loc.Tagline,
|
|
loc.PosterPath, loc.PosterThumbhash, loc.BackdropPath, loc.BackdropThumbhash, loc.LogoPath)
|
|
if err != nil {
|
|
return fmt.Errorf("upserting media item localization: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// UpsertAITranslation writes AI-translated overview/tagline values. A nil
|
|
// pointer leaves that field untouched. Per field, the write lands when the
|
|
// existing value is empty or already AI-sourced; force additionally overwrites
|
|
// provider values (the admin explicitly asked to re-translate). Manual values
|
|
// are never overwritten. Rows created here carry empty titles/artwork — the
|
|
// serving layer falls back to the base item for empty localized fields.
|
|
func (r *MediaItemLocalizationRepository) UpsertAITranslation(ctx context.Context, contentID, language string, overview, tagline *string, force bool) error {
|
|
if contentID == "" || language == "" {
|
|
return fmt.Errorf("invalid media item AI localization")
|
|
}
|
|
if overview == nil && tagline == nil {
|
|
return nil
|
|
}
|
|
|
|
_, err := r.pool.Exec(ctx, `
|
|
INSERT INTO media_item_localizations (
|
|
content_id, language, title, sort_title, overview, tagline,
|
|
poster_path, poster_thumbhash, backdrop_path, backdrop_thumbhash, logo_path,
|
|
overview_source, tagline_source
|
|
) VALUES (
|
|
$1, $2, '', '', COALESCE($3, ''), COALESCE($4, ''),
|
|
'', '', '', '', '',
|
|
CASE WHEN $3::text IS NULL THEN 'provider' ELSE 'ai' END,
|
|
CASE WHEN $4::text IS NULL THEN 'provider' ELSE 'ai' END
|
|
)
|
|
ON CONFLICT (content_id, language) DO UPDATE SET
|
|
overview = CASE
|
|
WHEN $3::text IS NULL OR media_item_localizations.overview_source = 'manual'
|
|
THEN media_item_localizations.overview
|
|
WHEN $5 OR media_item_localizations.overview_source = 'ai' OR media_item_localizations.overview = ''
|
|
THEN EXCLUDED.overview
|
|
ELSE media_item_localizations.overview END,
|
|
overview_source = CASE
|
|
WHEN $3::text IS NULL OR media_item_localizations.overview_source = 'manual'
|
|
THEN media_item_localizations.overview_source
|
|
WHEN $5 OR media_item_localizations.overview_source = 'ai' OR media_item_localizations.overview = ''
|
|
THEN 'ai'
|
|
ELSE media_item_localizations.overview_source END,
|
|
tagline = CASE
|
|
WHEN $4::text IS NULL OR media_item_localizations.tagline_source = 'manual'
|
|
THEN media_item_localizations.tagline
|
|
WHEN $5 OR media_item_localizations.tagline_source = 'ai' OR media_item_localizations.tagline = ''
|
|
THEN EXCLUDED.tagline
|
|
ELSE media_item_localizations.tagline END,
|
|
tagline_source = CASE
|
|
WHEN $4::text IS NULL OR media_item_localizations.tagline_source = 'manual'
|
|
THEN media_item_localizations.tagline_source
|
|
WHEN $5 OR media_item_localizations.tagline_source = 'ai' OR media_item_localizations.tagline = ''
|
|
THEN 'ai'
|
|
ELSE media_item_localizations.tagline_source END,
|
|
updated_at = NOW()
|
|
`, contentID, language, overview, tagline, force)
|
|
if err != nil {
|
|
return fmt.Errorf("upserting media item AI localization: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *MediaItemLocalizationRepository) Get(ctx context.Context, contentID, language string) (*models.MediaItemLocalization, error) {
|
|
row := r.pool.QueryRow(ctx, `
|
|
SELECT content_id, language, title, sort_title, overview, tagline,
|
|
poster_path, poster_thumbhash, backdrop_path, backdrop_thumbhash, logo_path,
|
|
overview_source, tagline_source,
|
|
created_at, updated_at
|
|
FROM media_item_localizations
|
|
WHERE content_id = $1 AND language = $2
|
|
`, contentID, language)
|
|
return scanMediaItemLocalization(row)
|
|
}
|
|
|
|
func (r *MediaItemLocalizationRepository) GetByContentIDs(ctx context.Context, contentIDs []string, language string) (map[string]*models.MediaItemLocalization, error) {
|
|
result := make(map[string]*models.MediaItemLocalization, len(contentIDs))
|
|
if len(contentIDs) == 0 || language == "" {
|
|
return result, nil
|
|
}
|
|
rows, err := r.pool.Query(ctx, `
|
|
SELECT content_id, language, title, sort_title, overview, tagline,
|
|
poster_path, poster_thumbhash, backdrop_path, backdrop_thumbhash, logo_path,
|
|
overview_source, tagline_source,
|
|
created_at, updated_at
|
|
FROM media_item_localizations
|
|
WHERE language = $1 AND content_id = ANY($2)
|
|
`, language, contentIDs)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("querying media item localizations: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
loc, scanErr := scanMediaItemLocalization(rows)
|
|
if scanErr != nil {
|
|
return nil, scanErr
|
|
}
|
|
result[loc.ContentID] = loc
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("iterating media item localizations: %w", err)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
type SeasonLocalizationRepository struct {
|
|
pool *pgxpool.Pool
|
|
}
|
|
|
|
func NewSeasonLocalizationRepository(pool *pgxpool.Pool) *SeasonLocalizationRepository {
|
|
return &SeasonLocalizationRepository{pool: pool}
|
|
}
|
|
|
|
// Upsert writes a provider localization; overview respects provenance (see
|
|
// MediaItemLocalizationRepository.Upsert).
|
|
func (r *SeasonLocalizationRepository) Upsert(ctx context.Context, loc *models.SeasonLocalization) error {
|
|
if loc == nil || loc.SeasonContentID == "" || loc.Language == "" {
|
|
return fmt.Errorf("invalid season localization")
|
|
}
|
|
_, err := r.pool.Exec(ctx, `
|
|
INSERT INTO season_localizations (
|
|
season_content_id, language, title, overview, poster_path, poster_thumbhash
|
|
) VALUES ($1, $2, $3, $4, $5, $6)
|
|
ON CONFLICT (season_content_id, language) DO UPDATE SET
|
|
title = EXCLUDED.title,
|
|
overview = CASE
|
|
WHEN season_localizations.overview_source = 'manual' OR EXCLUDED.overview = ''
|
|
THEN season_localizations.overview
|
|
ELSE EXCLUDED.overview END,
|
|
overview_source = CASE
|
|
WHEN season_localizations.overview_source = 'manual' OR EXCLUDED.overview = ''
|
|
THEN season_localizations.overview_source
|
|
ELSE 'provider' END,
|
|
poster_path = EXCLUDED.poster_path,
|
|
poster_thumbhash = EXCLUDED.poster_thumbhash,
|
|
updated_at = NOW()
|
|
`, loc.SeasonContentID, loc.Language, loc.Title, loc.Overview, loc.PosterPath, loc.PosterThumbhash)
|
|
if err != nil {
|
|
return fmt.Errorf("upserting season localization: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// UpsertAIOverview writes an AI-translated season overview (see
|
|
// MediaItemLocalizationRepository.UpsertAITranslation for the precedence).
|
|
func (r *SeasonLocalizationRepository) UpsertAIOverview(ctx context.Context, seasonContentID, language, overview string, force bool) error {
|
|
if seasonContentID == "" || language == "" {
|
|
return fmt.Errorf("invalid season AI localization")
|
|
}
|
|
_, err := r.pool.Exec(ctx, `
|
|
INSERT INTO season_localizations (
|
|
season_content_id, language, title, overview, poster_path, poster_thumbhash, overview_source
|
|
) VALUES ($1, $2, '', $3, '', '', 'ai')
|
|
ON CONFLICT (season_content_id, language) DO UPDATE SET
|
|
overview = CASE
|
|
WHEN season_localizations.overview_source = 'manual'
|
|
THEN season_localizations.overview
|
|
WHEN $4 OR season_localizations.overview_source = 'ai' OR season_localizations.overview = ''
|
|
THEN EXCLUDED.overview
|
|
ELSE season_localizations.overview END,
|
|
overview_source = CASE
|
|
WHEN season_localizations.overview_source = 'manual'
|
|
THEN season_localizations.overview_source
|
|
WHEN $4 OR season_localizations.overview_source = 'ai' OR season_localizations.overview = ''
|
|
THEN 'ai'
|
|
ELSE season_localizations.overview_source END,
|
|
updated_at = NOW()
|
|
`, seasonContentID, language, overview, force)
|
|
if err != nil {
|
|
return fmt.Errorf("upserting season AI localization: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *SeasonLocalizationRepository) Get(ctx context.Context, seasonContentID, language string) (*models.SeasonLocalization, error) {
|
|
row := r.pool.QueryRow(ctx, `
|
|
SELECT season_content_id, language, title, overview, poster_path, poster_thumbhash,
|
|
overview_source, created_at, updated_at
|
|
FROM season_localizations
|
|
WHERE season_content_id = $1 AND language = $2
|
|
`, seasonContentID, language)
|
|
return scanSeasonLocalization(row)
|
|
}
|
|
|
|
func (r *SeasonLocalizationRepository) GetBySeasonIDs(ctx context.Context, seasonIDs []string, language string) (map[string]*models.SeasonLocalization, error) {
|
|
result := make(map[string]*models.SeasonLocalization, len(seasonIDs))
|
|
if len(seasonIDs) == 0 || language == "" {
|
|
return result, nil
|
|
}
|
|
rows, err := r.pool.Query(ctx, `
|
|
SELECT season_content_id, language, title, overview, poster_path, poster_thumbhash,
|
|
overview_source, created_at, updated_at
|
|
FROM season_localizations
|
|
WHERE language = $1 AND season_content_id = ANY($2)
|
|
`, language, seasonIDs)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("querying season localizations: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
loc, scanErr := scanSeasonLocalization(rows)
|
|
if scanErr != nil {
|
|
return nil, scanErr
|
|
}
|
|
result[loc.SeasonContentID] = loc
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("iterating season localizations: %w", err)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
type EpisodeLocalizationRepository struct {
|
|
pool *pgxpool.Pool
|
|
}
|
|
|
|
func NewEpisodeLocalizationRepository(pool *pgxpool.Pool) *EpisodeLocalizationRepository {
|
|
return &EpisodeLocalizationRepository{pool: pool}
|
|
}
|
|
|
|
// Upsert writes a provider localization; overview respects provenance (see
|
|
// MediaItemLocalizationRepository.Upsert).
|
|
func (r *EpisodeLocalizationRepository) Upsert(ctx context.Context, loc *models.EpisodeLocalization) error {
|
|
if loc == nil || loc.EpisodeContentID == "" || loc.Language == "" {
|
|
return fmt.Errorf("invalid episode localization")
|
|
}
|
|
_, err := r.pool.Exec(ctx, `
|
|
INSERT INTO episode_localizations (episode_content_id, language, title, overview)
|
|
VALUES ($1, $2, $3, $4)
|
|
ON CONFLICT (episode_content_id, language) DO UPDATE SET
|
|
title = EXCLUDED.title,
|
|
overview = CASE
|
|
WHEN episode_localizations.overview_source = 'manual' OR EXCLUDED.overview = ''
|
|
THEN episode_localizations.overview
|
|
ELSE EXCLUDED.overview END,
|
|
overview_source = CASE
|
|
WHEN episode_localizations.overview_source = 'manual' OR EXCLUDED.overview = ''
|
|
THEN episode_localizations.overview_source
|
|
ELSE 'provider' END,
|
|
updated_at = NOW()
|
|
`, loc.EpisodeContentID, loc.Language, loc.Title, loc.Overview)
|
|
if err != nil {
|
|
return fmt.Errorf("upserting episode localization: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// UpsertAIOverview writes an AI-translated episode overview (see
|
|
// MediaItemLocalizationRepository.UpsertAITranslation for the precedence).
|
|
func (r *EpisodeLocalizationRepository) UpsertAIOverview(ctx context.Context, episodeContentID, language, overview string, force bool) error {
|
|
if episodeContentID == "" || language == "" {
|
|
return fmt.Errorf("invalid episode AI localization")
|
|
}
|
|
_, err := r.pool.Exec(ctx, `
|
|
INSERT INTO episode_localizations (episode_content_id, language, title, overview, overview_source)
|
|
VALUES ($1, $2, '', $3, 'ai')
|
|
ON CONFLICT (episode_content_id, language) DO UPDATE SET
|
|
overview = CASE
|
|
WHEN episode_localizations.overview_source = 'manual'
|
|
THEN episode_localizations.overview
|
|
WHEN $4 OR episode_localizations.overview_source = 'ai' OR episode_localizations.overview = ''
|
|
THEN EXCLUDED.overview
|
|
ELSE episode_localizations.overview END,
|
|
overview_source = CASE
|
|
WHEN episode_localizations.overview_source = 'manual'
|
|
THEN episode_localizations.overview_source
|
|
WHEN $4 OR episode_localizations.overview_source = 'ai' OR episode_localizations.overview = ''
|
|
THEN 'ai'
|
|
ELSE episode_localizations.overview_source END,
|
|
updated_at = NOW()
|
|
`, episodeContentID, language, overview, force)
|
|
if err != nil {
|
|
return fmt.Errorf("upserting episode AI localization: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *EpisodeLocalizationRepository) Get(ctx context.Context, episodeContentID, language string) (*models.EpisodeLocalization, error) {
|
|
row := r.pool.QueryRow(ctx, `
|
|
SELECT episode_content_id, language, title, overview, overview_source, created_at, updated_at
|
|
FROM episode_localizations
|
|
WHERE episode_content_id = $1 AND language = $2
|
|
`, episodeContentID, language)
|
|
return scanEpisodeLocalization(row)
|
|
}
|
|
|
|
func (r *EpisodeLocalizationRepository) GetByEpisodeIDs(ctx context.Context, episodeIDs []string, language string) (map[string]*models.EpisodeLocalization, error) {
|
|
result := make(map[string]*models.EpisodeLocalization, len(episodeIDs))
|
|
if len(episodeIDs) == 0 || language == "" {
|
|
return result, nil
|
|
}
|
|
rows, err := r.pool.Query(ctx, `
|
|
SELECT episode_content_id, language, title, overview, overview_source, created_at, updated_at
|
|
FROM episode_localizations
|
|
WHERE language = $1 AND episode_content_id = ANY($2)
|
|
`, language, episodeIDs)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("querying episode localizations: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
loc, scanErr := scanEpisodeLocalization(rows)
|
|
if scanErr != nil {
|
|
return nil, scanErr
|
|
}
|
|
result[loc.EpisodeContentID] = loc
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("iterating episode localizations: %w", err)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func scanMediaItemLocalization(row pgx.Row) (*models.MediaItemLocalization, error) {
|
|
var loc models.MediaItemLocalization
|
|
if err := row.Scan(
|
|
&loc.ContentID,
|
|
&loc.Language,
|
|
&loc.Title,
|
|
&loc.SortTitle,
|
|
&loc.Overview,
|
|
&loc.Tagline,
|
|
&loc.PosterPath,
|
|
&loc.PosterThumbhash,
|
|
&loc.BackdropPath,
|
|
&loc.BackdropThumbhash,
|
|
&loc.LogoPath,
|
|
&loc.OverviewSource,
|
|
&loc.TaglineSource,
|
|
&loc.CreatedAt,
|
|
&loc.UpdatedAt,
|
|
); err != nil {
|
|
if err == pgx.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
return nil, fmt.Errorf("scanning media item localization: %w", err)
|
|
}
|
|
return &loc, nil
|
|
}
|
|
|
|
func scanSeasonLocalization(row pgx.Row) (*models.SeasonLocalization, error) {
|
|
var loc models.SeasonLocalization
|
|
if err := row.Scan(
|
|
&loc.SeasonContentID,
|
|
&loc.Language,
|
|
&loc.Title,
|
|
&loc.Overview,
|
|
&loc.PosterPath,
|
|
&loc.PosterThumbhash,
|
|
&loc.OverviewSource,
|
|
&loc.CreatedAt,
|
|
&loc.UpdatedAt,
|
|
); err != nil {
|
|
if err == pgx.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
return nil, fmt.Errorf("scanning season localization: %w", err)
|
|
}
|
|
return &loc, nil
|
|
}
|
|
|
|
func scanEpisodeLocalization(row pgx.Row) (*models.EpisodeLocalization, error) {
|
|
var loc models.EpisodeLocalization
|
|
if err := row.Scan(
|
|
&loc.EpisodeContentID,
|
|
&loc.Language,
|
|
&loc.Title,
|
|
&loc.Overview,
|
|
&loc.OverviewSource,
|
|
&loc.CreatedAt,
|
|
&loc.UpdatedAt,
|
|
); err != nil {
|
|
if err == pgx.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
return nil, fmt.Errorf("scanning episode localization: %w", err)
|
|
}
|
|
return &loc, nil
|
|
}
|