Files
silo-server/internal/catalog/update.go
14ffc91dfb [codex] Expand provider image cache queue (#176)
* 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>
2026-06-18 10:07:58 -04:00

117 lines
3.2 KiB
Go

package catalog
import (
"context"
"slices"
"strings"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/titleutil"
)
// MetadataUpdate contains the fields that can be updated on a media item,
// season, or episode. Nil pointer fields are skipped (not updated).
type MetadataUpdate struct {
Title *string
SortTitle *string
OriginalTitle *string
Overview *string
Tagline *string
ContentRating *string
Year *int
Runtime *int
Genres *[]string
Studios *[]string
Networks *[]string
Countries *[]string
ReleaseDate *string
FirstAirDate *string
LastAirDate *string
AirTime *string
AirTimezone *string
AirDate *string
Status *string
ShowStatus *string
RatingIMDB *float64
RatingTMDB *float64
RatingRTCritic *int
RatingRTAudience *int
ImdbID *string
TmdbID *string
TvdbID *string
SeasonNumber *int
EpisodeNumber *int
LockedFields *[]int
PosterPath *string
PosterSourcePath *string
PosterThumbhash *string
BackdropPath *string
BackdropSourcePath *string
BackdropThumbhash *string
LogoPath *string
LogoSourcePath *string
StillPath *string
StillSourcePath *string
StillThumbhash *string
}
// UpdateMediaItemMetadata updates specific metadata fields on a media_items row.
func (s *DetailService) UpdateMediaItemMetadata(ctx context.Context, contentID string, upd *MetadataUpdate) error {
if err := applyDefaultSortTitleOnAdminUpdate(ctx, s.itemRepo, contentID, upd); err != nil {
return err
}
return s.itemRepo.UpdateMetadata(ctx, contentID, upd)
}
// UpdateSeasonMetadata updates specific metadata fields on a seasons row.
func (s *DetailService) UpdateSeasonMetadata(ctx context.Context, contentID string, upd *MetadataUpdate) error {
return s.seasonRepo.UpdateMetadata(ctx, contentID, upd)
}
// UpdateEpisodeMetadata updates specific metadata fields on an episodes row.
func (s *DetailService) UpdateEpisodeMetadata(ctx context.Context, contentID string, upd *MetadataUpdate) error {
return s.episodeRepo.UpdateMetadata(ctx, contentID, upd)
}
func applyDefaultSortTitleOnAdminUpdate(
ctx context.Context,
itemRepo *ItemRepository,
contentID string,
upd *MetadataUpdate,
) error {
if upd.Title == nil || upd.SortTitle != nil {
return nil
}
item, err := itemRepo.GetByID(ctx, contentID)
if err != nil {
return err
}
if item == nil {
return nil
}
if titleLockedForAdminUpdate(item, upd) {
return nil
}
if strings.TrimSpace(item.SortTitle) != "" {
return nil
}
derived := titleutil.DeriveDefaultSortTitle(*upd.Title)
if derived == "" {
return nil
}
upd.SortTitle = &derived
return nil
}
// fieldNameLocked matches metadata.FieldName and EditMetadataDialog FIELD_NAME.
const fieldNameLocked = 0
func titleLockedForAdminUpdate(item *models.MediaItem, upd *MetadataUpdate) bool {
if upd.LockedFields != nil {
return slices.Contains(*upd.LockedFields, fieldNameLocked)
}
return slices.Contains(item.LockedFields, fieldNameLocked)
}