* feat(metadata): expand provider image cache queue * fix(metadata): harden provider image cache queue Addresses bug-review feedback from Codex/CodeRabbit on the metadata image cache pipeline. All findings validated against the code before fixing; false positives (rows/connection deadlock, PhotoSourcePath merge coupling) were confirmed non-issues and left unchanged. - Honor metadata.cache_images for the background processor. The cache_metadata_images task was registered whenever S3 was configured, so merely enabling object storage downloaded the entire provider-artwork catalog even with caching disabled. Add ImageCacheProcessor.SetEnabled, gate RunOnce/RunUntilIdle on it, and wire it (with hot reload) from cfg.Metadata.CacheImages in main.go. - Guard terminal job updates with lease ownership. EnqueueBatch can repurpose a running row with a new source; MarkSucceeded/MarkFailed keyed on id alone let a stale worker finalize the replacement job and drop the new artwork. Thread locked_by through and add status='running' AND locked_by=$n guards. - Avoid uploading stale jobs onto the live artwork key. Verify the target still references the job's source (CurrentTargetSourcePath) before CacheImage, so a job whose source an admin/refresh already replaced cannot overwrite the deterministic storage object. - COALESCE nullable external IDs in EnqueueExistingProviderArtwork. A NULL tmdb_id/tvdb_id/imdb_id on any candidate failed the scan and aborted the whole cache run; matches the existing item_repo pattern. - Stop re-downloading the catalog every 30 days. Discovery now skips targets whose *_path is already a cached relative path, making the cached row the durable dedup marker instead of the prunable job row. - Decouple catalog sweeps from queue draining. RunOnce no longer runs discovery per batch; RunUntilIdle sweeps only when the queue drains and throttles full sweeps to every 15m, so idle installs stop full-scanning every entity table each minute. - Requeue claimed-but-unstarted jobs on cancellation. Acquire the semaphore before spawning workers and RequeueClaimed any jobs not yet started, instead of leaving them locked until the 15m lease expires. - Skip the backoff sleep after the final upload attempt in putObjectWithRetry (saves ~1.5s on permanent failures). - Add the s3/file/local/upload/generated exclusion to the seasons and episodes backfill in migration 20260617184537 for consistency with the later migration (the bad backfill was inert downstream, but the asymmetry is removed). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
80 lines
2.4 KiB
Go
80 lines
2.4 KiB
Go
package catalog
|
|
|
|
import "github.com/Silo-Server/silo-server/internal/models"
|
|
|
|
// applyItemLocalization merges a localization onto a clone of item. Only
|
|
// non-empty localized fields override the base — localization rows are
|
|
// legitimately partial (an AI translation carries only overview/tagline; a
|
|
// provider row may lack a tagline or logo), and an empty field must fall back
|
|
// to the base value rather than blank it.
|
|
func applyItemLocalization(item *models.MediaItem, loc *models.MediaItemLocalization) *models.MediaItem {
|
|
localized := cloneMediaItem(item)
|
|
if localized == nil || loc == nil {
|
|
return localized
|
|
}
|
|
if loc.Title != "" {
|
|
localized.Title = loc.Title
|
|
}
|
|
if loc.SortTitle != "" {
|
|
localized.SortTitle = loc.SortTitle
|
|
}
|
|
if loc.Overview != "" {
|
|
localized.Overview = loc.Overview
|
|
}
|
|
if loc.Tagline != "" {
|
|
localized.Tagline = loc.Tagline
|
|
}
|
|
if loc.PosterPath != "" {
|
|
localized.PosterPath = loc.PosterPath
|
|
localized.PosterSourcePath = loc.PosterSourcePath
|
|
localized.PosterThumbhash = loc.PosterThumbhash
|
|
}
|
|
if loc.BackdropPath != "" {
|
|
localized.BackdropPath = loc.BackdropPath
|
|
localized.BackdropSourcePath = loc.BackdropSourcePath
|
|
localized.BackdropThumbhash = loc.BackdropThumbhash
|
|
}
|
|
if loc.LogoPath != "" {
|
|
localized.LogoPath = loc.LogoPath
|
|
localized.LogoSourcePath = loc.LogoSourcePath
|
|
}
|
|
return localized
|
|
}
|
|
|
|
// applySeasonLocalization merges a localization onto a clone of season; see
|
|
// applyItemLocalization for the empty-field semantics.
|
|
func applySeasonLocalization(season *models.Season, loc *models.SeasonLocalization) *models.Season {
|
|
localized := cloneSeason(season)
|
|
if localized == nil || loc == nil {
|
|
return localized
|
|
}
|
|
if loc.Title != "" {
|
|
localized.Title = loc.Title
|
|
}
|
|
if loc.Overview != "" {
|
|
localized.Overview = loc.Overview
|
|
}
|
|
if loc.PosterPath != "" {
|
|
localized.PosterPath = loc.PosterPath
|
|
localized.PosterSourcePath = loc.PosterSourcePath
|
|
localized.PosterThumbhash = loc.PosterThumbhash
|
|
}
|
|
return localized
|
|
}
|
|
|
|
// applyEpisodeLocalization merges a localization onto a clone of episode; see
|
|
// applyItemLocalization for the empty-field semantics.
|
|
func applyEpisodeLocalization(episode *models.Episode, loc *models.EpisodeLocalization) *models.Episode {
|
|
localized := cloneEpisode(episode)
|
|
if localized == nil || loc == nil {
|
|
return localized
|
|
}
|
|
if loc.Title != "" {
|
|
localized.Title = loc.Title
|
|
}
|
|
if loc.Overview != "" {
|
|
localized.Overview = loc.Overview
|
|
}
|
|
return localized
|
|
}
|