Files
silo-server/internal/metadata/person_refresh.go
1664c60425 fix(metadata): publish artwork revisions atomically (#399)
* fix(metadata): publish artwork revisions atomically

* fix(metadata): harden artwork revision cleanup

* fix(metadata): address artwork revision review findings

- restore image applies for all media_items types and reject unsupported
  target/image combinations with 400 before uploading; episodes coerce to
  stills and the web dialog no longer offers image tabs episodes can't use
- add WHEN clauses to displacement triggers and hoist to_jsonb so bulk
  catalog upserts that assign unchanged artwork columns skip the trigger
- make artworkkey the single variant-ladder owner: imagecache derives its
  widths from it and triggers store image_type instead of hardcoded
  variant arrays, expanded by the collector at deletion time
- sweep dormant registry rows periodically so references lost through
  untriggered surfaces degrade to slow cleanup instead of leaking
- park just-published revisions dormant, keep dormant rows dormant on
  re-cache, and batch the GC reference pre-check per run
- heal rows re-referencing a just-deleted revision via reconciler-style
  resets after the deletion commits
- share a per-URL image-loaded hook across DetailHero, ItemCard,
  SectionItemCard, GlobalSearch, and CollectionPosterCard
- deduplicate Cache/CacheBytes finalization and drop unused VariantPaths
  plumbing

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(catalog): cast reused timestamp parameter in revision upsert

Postgres cannot deduce one type for $3 used both as a plain value and
inside a CASE arm; the dev deploy surfaced it as SQLSTATE 42P08 on every
publication. Cast both uses and cover the arm/park/track upserts with
database-backed tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(metadata): address artwork revision review comments

- keep a durable heal path: deletion marks deleted_at instead of removing
  the registry row, so a failed post-delete heal retries with backoff and
  broken references never park; trackers clear the marker on re-upload
- never treat bare existence as an immutable-content match; backends
  without content verification rewrite the object
- exercise revisioned cover keys in scanner/enrichment fakes, compare the
  tracked manifest exactly, and honor cancellation in the blocking test
  deleter

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 17:33:21 -04:00

373 lines
10 KiB
Go

package metadata
import (
"context"
"errors"
"fmt"
"log/slog"
"strconv"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/models"
)
var (
ErrPersonNotFound = errors.New("person not found")
ErrPersonMetadataNotFound = errors.New("no person metadata found from any provider")
)
type personRefreshRepo interface {
Get(ctx context.Context, id int64) (*models.Person, error)
Update(ctx context.Context, person models.Person) error
FindRefreshCandidates(ctx context.Context, staleAfter time.Duration, limit int) ([]int64, error)
}
type PersonRefreshService struct {
pool *pgxpool.Pool
pluginResolver pluginMetadataResolver
repo personRefreshRepo
imageCacher ImageCacher
imageCacheJobs ImageCacheJobEnqueuer
imageResolver interface {
ResolveImageURL(ctx context.Context, path string, variant string) string
}
}
func NewPersonRefreshService(
pool *pgxpool.Pool,
pluginResolver pluginMetadataResolver,
repo *catalog.PersonRepository,
) *PersonRefreshService {
return &PersonRefreshService{
pool: pool,
pluginResolver: pluginResolver,
repo: repo,
}
}
func (s *PersonRefreshService) SetImageCacher(cacher ImageCacher) {
s.imageCacher = cacher
}
func (s *PersonRefreshService) SetImageCacheJobEnqueuer(enqueuer ImageCacheJobEnqueuer) {
s.imageCacheJobs = enqueuer
}
func (s *PersonRefreshService) SetImageResolver(resolver interface {
ResolveImageURL(ctx context.Context, path string, variant string) string
}) {
s.imageResolver = resolver
}
func (s *PersonRefreshService) RefreshPerson(ctx context.Context, id int64) (*models.Person, error) {
if s.repo == nil {
return nil, fmt.Errorf("person refresh repository is not configured")
}
if s.pluginResolver == nil || s.pool == nil {
return nil, fmt.Errorf("person refresh providers are not configured")
}
// Person refresh is a background path; the nil checker falls back to a
// direct pool query rather than the hot-path installation cache.
providers, err := resolveEnabledProviders(ctx, s.pluginResolver, s.pool, nil)
if err != nil {
return nil, fmt.Errorf("resolve person providers: %w", err)
}
return s.refreshPersonWithProviders(ctx, id, providers)
}
func (s *PersonRefreshService) FindCandidates(
ctx context.Context,
staleAfter time.Duration,
limit int,
) ([]int64, error) {
if s.repo == nil {
return nil, fmt.Errorf("person refresh repository is not configured")
}
return s.repo.FindRefreshCandidates(ctx, staleAfter, limit)
}
func (s *PersonRefreshService) refreshPersonWithProviders(
ctx context.Context,
id int64,
providers []Provider,
) (*models.Person, error) {
person, err := s.repo.Get(ctx, id)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrPersonNotFound
}
return nil, fmt.Errorf("load person %d: %w", id, err)
}
if person == nil {
return nil, ErrPersonNotFound
}
accumulator := PersonDetailResult{
ProviderIDs: copyMap(personProviderIDs(*person)),
}
photoProviderID := ""
hasMetadata := false
for _, provider := range providers {
personProvider, ok := provider.(PersonProvider)
if !ok {
continue
}
result, err := personProvider.GetPersonDetail(ctx, PersonDetailRequest{
ProviderIDs: accumulator.ProviderIDs,
Language: "en",
})
if err != nil {
slog.WarnContext(ctx, "person refresh: provider detail lookup failed", "component", "metadata",
"provider", provider.Slug(),
"person_id", id,
"error", err,
)
continue
}
if result == nil {
continue
}
hasMetadata = true
if photoProviderID == "" && strings.TrimSpace(result.PhotoPath) != "" {
photoProviderID = provider.Slug()
}
MergePersonDetail(result, &accumulator, MergeFillEmpty)
}
if !hasMetadata {
return nil, ErrPersonMetadataNotFound
}
accumulator.PhotoSourcePath = providerImageSourcePath(accumulator.PhotoPath)
if accumulator.PhotoSourcePath != "" {
accumulator.PhotoPath, accumulator.PhotoThumbhash, accumulator.PhotoSourcePath = preserveCachedArtwork(
accumulator.PhotoPath,
accumulator.PhotoThumbhash,
person.PhotoPath,
person.PhotoSourcePath,
person.PhotoThumbhash,
)
}
existingDetail := personToPersonDetailResult(*person)
MergePersonDetail(&accumulator, &existingDetail, MergeReplaceUnlocked)
accumulator = existingDetail
refreshed, err := mergePersonIntoRecord(*person, accumulator)
if err != nil {
return nil, err
}
if err := s.repo.Update(ctx, refreshed); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
// The row was merged into another person concurrently; there is no
// longer anything to refresh under this id.
return nil, ErrPersonNotFound
}
return nil, fmt.Errorf("update person %d: %w", id, err)
}
s.enqueuePersonPhoto(ctx, refreshed, accumulator.ProviderIDs, photoProviderID)
return &refreshed, nil
}
func (s *PersonRefreshService) enqueuePersonPhoto(ctx context.Context, person models.Person, providerIDs map[string]string, photoProviderID string) {
if s == nil || s.imageCacheJobs == nil || !isRemoteImageSourcePath(person.PhotoSourcePath) {
return
}
providerID := providerIDFromPluginURL(person.PhotoSourcePath)
if providerID == "" {
providerID = photoProviderID
}
if providerID == "" {
providerID = primaryPersonProviderID(providerIDs)
}
if providerID == "" {
providerID = "remote"
}
contentID := personCacheContentID(person, providerIDs, providerID)
enqueueCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second)
defer cancel()
if err := s.imageCacheJobs.Enqueue(enqueueCtx, EnqueueImageCacheJobInput{
TargetType: ImageCacheTargetPerson,
TargetContentID: strconv.FormatInt(person.ID, 10),
SourcePath: person.PhotoSourcePath,
ProviderID: providerID,
ProviderContentID: contentID,
ContentType: "people",
ImageType: ImageCacheImageProfile,
}); err != nil {
slog.WarnContext(ctx, "person refresh: failed to enqueue photo cache job", "component", "metadata",
"person_id", person.ID,
"provider", providerID,
"error", err,
)
}
}
func (s *PersonRefreshService) cachePersonPhoto(
ctx context.Context,
person models.Person,
detail PersonDetailResult,
photoProviderID string,
) (string, string, error) {
if s.imageCacher == nil {
return detail.PhotoPath, detail.PhotoThumbhash, nil
}
if strings.TrimSpace(detail.PhotoPath) == "" || detail.PhotoPath == "-" {
return detail.PhotoPath, detail.PhotoThumbhash, nil
}
if !strings.Contains(detail.PhotoPath, "://") {
return detail.PhotoPath, detail.PhotoThumbhash, nil
}
downloadURL := detail.PhotoPath
if !strings.HasPrefix(downloadURL, "http://") && !strings.HasPrefix(downloadURL, "https://") {
if s.imageResolver == nil {
return "", "", fmt.Errorf("plugin image resolver is not configured")
}
downloadURL = s.imageResolver.ResolveImageURL(ctx, detail.PhotoPath, "original")
if downloadURL == "" {
return "", "", fmt.Errorf("resolved empty URL for %q", detail.PhotoPath)
}
}
providerID := photoProviderID
if providerID == "" {
providerID = primaryPersonProviderID(detail.ProviderIDs)
}
if providerID == "" {
providerID = "unknown"
}
contentID := personCacheContentID(person, detail.ProviderIDs, providerID)
result, err := s.imageCacher.CacheImage(ctx, CacheImageRequest{
SourceURL: downloadURL,
ProviderID: providerID,
ContentType: "people",
ContentID: contentID,
ImageType: ImagePoster,
})
if err != nil {
return "", "", err
}
return CachedImageOriginalPath(result), result.Thumbhash, nil
}
func personProviderIDs(person models.Person) map[string]string {
ids := map[string]string{}
if person.TmdbID != "" {
ids["tmdb"] = person.TmdbID
}
if person.ImdbID != "" {
ids["imdb"] = person.ImdbID
}
if person.TvdbID != "" {
ids["tvdb"] = person.TvdbID
}
if person.PlexGUID != "" {
ids["plex"] = person.PlexGUID
}
return ids
}
func personToPersonDetailResult(person models.Person) PersonDetailResult {
result := PersonDetailResult{
Name: person.Name,
SortName: person.SortName,
Bio: person.Bio,
Birthplace: person.Birthplace,
Homepage: person.Homepage,
PhotoPath: person.PhotoPath,
PhotoSourcePath: person.PhotoSourcePath,
PhotoThumbhash: person.PhotoThumbhash,
ProviderIDs: copyMap(personProviderIDs(person)),
}
if person.BirthDate != nil {
result.BirthDate = person.BirthDate.Format("2006-01-02")
}
if person.DeathDate != nil {
result.DeathDate = person.DeathDate.Format("2006-01-02")
}
return result
}
func mergePersonIntoRecord(person models.Person, detail PersonDetailResult) (models.Person, error) {
birthDate, err := parseOptionalPersonDate(detail.BirthDate)
if err != nil {
return person, fmt.Errorf("parse birth date for person %d: %w", person.ID, err)
}
deathDate, err := parseOptionalPersonDate(detail.DeathDate)
if err != nil {
return person, fmt.Errorf("parse death date for person %d: %w", person.ID, err)
}
person.Name = detail.Name
person.SortName = detail.SortName
person.Bio = detail.Bio
person.BirthDate = birthDate
person.DeathDate = deathDate
person.Birthplace = detail.Birthplace
person.Homepage = detail.Homepage
person.PhotoPath = detail.PhotoPath
person.PhotoSourcePath = detail.PhotoSourcePath
person.PhotoThumbhash = detail.PhotoThumbhash
person.TmdbID = detail.ProviderIDs["tmdb"]
person.ImdbID = detail.ProviderIDs["imdb"]
person.TvdbID = detail.ProviderIDs["tvdb"]
if plexID := detail.ProviderIDs["plex"]; plexID != "" {
person.PlexGUID = plexID
}
return person, nil
}
func parseOptionalPersonDate(value string) (*time.Time, error) {
value = strings.TrimSpace(value)
if value == "" {
return nil, nil
}
parsed, err := time.Parse("2006-01-02", value)
if err != nil {
return nil, err
}
return &parsed, nil
}
func primaryPersonProviderID(providerIDs map[string]string) string {
for _, key := range []string{"tmdb", "tvdb", "imdb", "metadb"} {
if providerIDs[key] != "" {
return key
}
}
return ""
}
func personCacheContentID(
person models.Person,
providerIDs map[string]string,
providerID string,
) string {
if providerID != "" && providerIDs[providerID] != "" {
return providerIDs[providerID]
}
for _, key := range []string{"tmdb", "tvdb", "imdb", "metadb"} {
if providerIDs[key] != "" {
return providerIDs[key]
}
}
return strconv.FormatInt(person.ID, 10)
}