2026-06-18 10:07:58 -04:00
|
|
|
package metadata
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
2026-07-17 05:55:36 +08:00
|
|
|
"crypto/sha256"
|
|
|
|
|
"encoding/hex"
|
|
|
|
|
"errors"
|
2026-06-18 10:07:58 -04:00
|
|
|
"fmt"
|
2026-07-17 05:55:36 +08:00
|
|
|
"io"
|
|
|
|
|
"io/fs"
|
2026-06-18 10:07:58 -04:00
|
|
|
"log/slog"
|
2026-07-17 05:55:36 +08:00
|
|
|
"os"
|
|
|
|
|
"path/filepath"
|
2026-06-18 10:07:58 -04:00
|
|
|
"strconv"
|
2026-07-17 05:55:36 +08:00
|
|
|
"strings"
|
2026-06-18 10:07:58 -04:00
|
|
|
"sync"
|
|
|
|
|
"sync/atomic"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"github.com/Silo-Server/silo-server/internal/models"
|
|
|
|
|
)
|
|
|
|
|
|
2026-07-17 05:55:36 +08:00
|
|
|
// maxLocalImageSourceBytes caps local sidecar artwork reads at 8 MiB,
|
|
|
|
|
// matching the NFO provider's discovery guard.
|
|
|
|
|
const maxLocalImageSourceBytes = 8 << 20
|
|
|
|
|
|
2026-06-18 10:07:58 -04:00
|
|
|
// imageCacheDiscoveryInterval throttles the full-catalog backfill sweep so an
|
|
|
|
|
// idle installation does not re-scan every entity table on every task tick.
|
|
|
|
|
// Draining of already-queued jobs is unaffected and stays responsive.
|
|
|
|
|
const imageCacheDiscoveryInterval = 15 * time.Minute
|
|
|
|
|
|
|
|
|
|
type ImageCacheJobClaimer interface {
|
|
|
|
|
ClaimDue(ctx context.Context, workerID string, limit int) ([]*models.MetadataImageCacheJob, error)
|
|
|
|
|
MarkSucceeded(ctx context.Context, id int64, lockedBy string) error
|
|
|
|
|
MarkFailed(ctx context.Context, id int64, attemptCount int, lockedBy string, errText string) error
|
|
|
|
|
RequeueClaimed(ctx context.Context, ids []int64, workerID string) error
|
|
|
|
|
CurrentTargetSourcePath(ctx context.Context, job *models.MetadataImageCacheJob) (string, error)
|
|
|
|
|
EnqueueExistingProviderArtwork(ctx context.Context, limit int) (int, error)
|
|
|
|
|
DeleteSucceededBefore(ctx context.Context, before time.Time, limit int) (int, error)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-17 05:55:36 +08:00
|
|
|
// imageCacheTargetCachedPathReader is optionally implemented by the job store
|
|
|
|
|
// (ImageCacheJobRepository does) to expose the target's currently stored
|
|
|
|
|
// cached path for the local-artwork unchanged-skip and stale-prefix cleanup.
|
|
|
|
|
type imageCacheTargetCachedPathReader interface {
|
|
|
|
|
CurrentTargetCachedPath(ctx context.Context, job *models.MetadataImageCacheJob) (string, error)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// LibraryRootResolver reports the media folder root paths a piece of content
|
|
|
|
|
// belongs to (media_folders.Paths via media_item_libraries). The processor
|
|
|
|
|
// confines local file:// sources to these roots before reading them.
|
|
|
|
|
type LibraryRootResolver interface {
|
|
|
|
|
LibraryRootsForContent(ctx context.Context, contentID string) ([]string, error)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ImagePrefixDeleter deletes cached image objects under a key prefix; used to
|
|
|
|
|
// sweep the previous hashed local/ prefix after a successful re-cache.
|
|
|
|
|
type ImagePrefixDeleter interface {
|
|
|
|
|
DeletePrefix(ctx context.Context, bucket, prefix string) (int, error)
|
|
|
|
|
Bucket() string
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-18 10:07:58 -04:00
|
|
|
type SeasonArtworkUpdater interface {
|
|
|
|
|
UpdateArtworkIfSourceMatches(ctx context.Context, contentID, sourcePath, cachedPath, thumbhash string) (bool, error)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type EpisodeStillUpdater interface {
|
|
|
|
|
UpdateStillIfSourceMatches(ctx context.Context, contentID, sourcePath, cachedPath, thumbhash string) (bool, error)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type ItemArtworkUpdater interface {
|
|
|
|
|
UpdateArtworkIfSourceMatches(ctx context.Context, contentID, imageType, sourcePath, cachedPath, thumbhash string) (bool, error)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type ItemLocalizationArtworkUpdater interface {
|
|
|
|
|
UpdateArtworkIfSourceMatches(ctx context.Context, contentID, language, imageType, sourcePath, cachedPath, thumbhash string) (bool, error)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type SeasonLocalizationArtworkUpdater interface {
|
|
|
|
|
UpdateArtworkIfSourceMatches(ctx context.Context, contentID, language, sourcePath, cachedPath, thumbhash string) (bool, error)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type PersonPhotoUpdater interface {
|
|
|
|
|
UpdatePhotoIfSourceMatches(ctx context.Context, personID int64, sourcePath, cachedPath, thumbhash string) (bool, error)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type ImageCacheProcessorTargets struct {
|
|
|
|
|
Items ItemArtworkUpdater
|
|
|
|
|
Seasons SeasonArtworkUpdater
|
|
|
|
|
Episodes EpisodeStillUpdater
|
|
|
|
|
ItemLocalizations ItemLocalizationArtworkUpdater
|
|
|
|
|
SeasonLocalizations SeasonLocalizationArtworkUpdater
|
|
|
|
|
People PersonPhotoUpdater
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type ImageCacheProcessor struct {
|
|
|
|
|
jobs ImageCacheJobClaimer
|
|
|
|
|
cacher ImageCacher
|
|
|
|
|
resolver interface {
|
|
|
|
|
ResolveImageURL(ctx context.Context, path string, variant string) string
|
|
|
|
|
}
|
|
|
|
|
targets ImageCacheProcessorTargets
|
|
|
|
|
logger *slog.Logger
|
|
|
|
|
|
2026-07-17 05:55:36 +08:00
|
|
|
// Local file:// artwork support. libraryRoots confines reads to the
|
|
|
|
|
// owning library's roots; prefixDeleter sweeps stale hashed prefixes.
|
|
|
|
|
// The processor host must mount the libraries, like the metadata worker.
|
|
|
|
|
libraryRoots LibraryRootResolver
|
|
|
|
|
prefixDeleter ImagePrefixDeleter
|
|
|
|
|
|
2026-06-18 10:07:58 -04:00
|
|
|
enabled atomic.Bool
|
|
|
|
|
|
|
|
|
|
discoveryInterval time.Duration
|
|
|
|
|
discoveryMu sync.Mutex
|
|
|
|
|
lastDiscovery time.Time
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-17 05:55:36 +08:00
|
|
|
// SetLibraryRootResolver wires the folder repository used to confine local
|
|
|
|
|
// file:// sources to their owning library's roots. Without it, local jobs
|
|
|
|
|
// fail (and retry on the normal backoff) rather than reading arbitrary paths.
|
|
|
|
|
func (p *ImageCacheProcessor) SetLibraryRootResolver(resolver LibraryRootResolver) {
|
|
|
|
|
if p == nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
p.libraryRoots = resolver
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// SetImagePrefixDeleter wires the object store used to delete the previous
|
|
|
|
|
// hashed local/ prefix after a successful local re-cache. Optional: without
|
|
|
|
|
// it, stale prefixes are left behind (item deletion still sweeps them).
|
|
|
|
|
func (p *ImageCacheProcessor) SetImagePrefixDeleter(deleter ImagePrefixDeleter) {
|
|
|
|
|
if p == nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
p.prefixDeleter = deleter
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-18 10:07:58 -04:00
|
|
|
// SetEnabled toggles background caching. When disabled the processor performs
|
|
|
|
|
// no discovery, claiming, or uploading, honoring metadata.cache_images so that
|
|
|
|
|
// merely configuring object storage does not download the whole catalog.
|
|
|
|
|
func (p *ImageCacheProcessor) SetEnabled(enabled bool) {
|
|
|
|
|
if p == nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
p.enabled.Store(enabled)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func NewImageCacheProcessor(
|
|
|
|
|
jobs ImageCacheJobClaimer,
|
|
|
|
|
cacher ImageCacher,
|
|
|
|
|
resolver interface {
|
|
|
|
|
ResolveImageURL(ctx context.Context, path string, variant string) string
|
|
|
|
|
},
|
|
|
|
|
seasons SeasonArtworkUpdater,
|
|
|
|
|
episodes EpisodeStillUpdater,
|
|
|
|
|
) *ImageCacheProcessor {
|
|
|
|
|
return NewImageCacheProcessorWithTargets(jobs, cacher, resolver, ImageCacheProcessorTargets{
|
|
|
|
|
Seasons: seasons,
|
|
|
|
|
Episodes: episodes,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func NewImageCacheProcessorWithTargets(
|
|
|
|
|
jobs ImageCacheJobClaimer,
|
|
|
|
|
cacher ImageCacher,
|
|
|
|
|
resolver interface {
|
|
|
|
|
ResolveImageURL(ctx context.Context, path string, variant string) string
|
|
|
|
|
},
|
|
|
|
|
targets ImageCacheProcessorTargets,
|
|
|
|
|
) *ImageCacheProcessor {
|
|
|
|
|
p := &ImageCacheProcessor{
|
|
|
|
|
jobs: jobs,
|
|
|
|
|
cacher: cacher,
|
|
|
|
|
resolver: resolver,
|
|
|
|
|
targets: targets,
|
|
|
|
|
logger: slog.Default(),
|
|
|
|
|
discoveryInterval: imageCacheDiscoveryInterval,
|
|
|
|
|
}
|
|
|
|
|
// Default to enabled; callers gate on metadata.cache_images via SetEnabled.
|
|
|
|
|
p.enabled.Store(true)
|
|
|
|
|
return p
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type ImageCacheRunStats struct {
|
|
|
|
|
Batches int
|
|
|
|
|
EnqueuedExisting int
|
|
|
|
|
Claimed int
|
|
|
|
|
Succeeded int
|
|
|
|
|
Failed int
|
|
|
|
|
Skipped int
|
|
|
|
|
DeletedSucceeded int
|
2026-07-01 23:37:06 +02:00
|
|
|
UploadedVariants int
|
|
|
|
|
ExistingVariants int
|
2026-06-18 10:07:58 -04:00
|
|
|
RuntimeLimited bool
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *ImageCacheRunStats) add(other ImageCacheRunStats) {
|
|
|
|
|
s.EnqueuedExisting += other.EnqueuedExisting
|
|
|
|
|
s.Claimed += other.Claimed
|
|
|
|
|
s.Succeeded += other.Succeeded
|
|
|
|
|
s.Failed += other.Failed
|
|
|
|
|
s.Skipped += other.Skipped
|
|
|
|
|
s.DeletedSucceeded += other.DeletedSucceeded
|
2026-07-01 23:37:06 +02:00
|
|
|
s.UploadedVariants += other.UploadedVariants
|
|
|
|
|
s.ExistingVariants += other.ExistingVariants
|
2026-06-18 10:07:58 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// RunOnce claims and processes one batch of already-queued jobs. It does not
|
|
|
|
|
// run catalog discovery; callers (RunUntilIdle) drive discovery on a throttled
|
|
|
|
|
// cadence so backlog draining stays decoupled from full-table sweeps.
|
|
|
|
|
func (p *ImageCacheProcessor) RunOnce(ctx context.Context, workerID string, claimLimit int, concurrency int) (ImageCacheRunStats, error) {
|
|
|
|
|
var stats ImageCacheRunStats
|
|
|
|
|
if p == nil || p.jobs == nil || p.cacher == nil || !p.enabled.Load() {
|
|
|
|
|
return stats, nil
|
|
|
|
|
}
|
|
|
|
|
if claimLimit <= 0 {
|
|
|
|
|
claimLimit = 100
|
|
|
|
|
}
|
|
|
|
|
if concurrency <= 0 {
|
|
|
|
|
concurrency = 4
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
jobs, err := p.jobs.ClaimDue(ctx, workerID, claimLimit)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return stats, err
|
|
|
|
|
}
|
|
|
|
|
stats.Claimed = len(jobs)
|
|
|
|
|
if len(jobs) == 0 {
|
|
|
|
|
p.cleanupSucceeded(ctx, &stats)
|
|
|
|
|
return stats, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
sem := make(chan struct{}, concurrency)
|
|
|
|
|
var wg sync.WaitGroup
|
|
|
|
|
var mu sync.Mutex
|
|
|
|
|
var unstarted []int64
|
|
|
|
|
loop:
|
|
|
|
|
for i, job := range jobs {
|
|
|
|
|
// Acquire the semaphore before spawning so cancellation is observed here
|
|
|
|
|
// rather than inside a goroutine that already holds a claimed job. Jobs we
|
|
|
|
|
// never start are requeued below instead of being left locked until the
|
|
|
|
|
// lease expires.
|
|
|
|
|
select {
|
|
|
|
|
case sem <- struct{}{}:
|
|
|
|
|
case <-ctx.Done():
|
|
|
|
|
for _, rem := range jobs[i:] {
|
|
|
|
|
unstarted = append(unstarted, rem.ID)
|
|
|
|
|
}
|
|
|
|
|
break loop
|
|
|
|
|
}
|
|
|
|
|
wg.Add(1)
|
|
|
|
|
go func(job *models.MetadataImageCacheJob) {
|
|
|
|
|
defer wg.Done()
|
|
|
|
|
defer func() { <-sem }()
|
2026-07-01 23:37:06 +02:00
|
|
|
result := p.processOne(ctx, job)
|
2026-06-18 10:07:58 -04:00
|
|
|
mu.Lock()
|
2026-07-01 23:37:06 +02:00
|
|
|
switch result.outcome {
|
2026-06-18 10:07:58 -04:00
|
|
|
case "succeeded":
|
|
|
|
|
stats.Succeeded++
|
|
|
|
|
case "skipped":
|
|
|
|
|
stats.Skipped++
|
|
|
|
|
default:
|
|
|
|
|
stats.Failed++
|
|
|
|
|
}
|
2026-07-01 23:37:06 +02:00
|
|
|
stats.UploadedVariants += result.uploadedVariants
|
|
|
|
|
stats.ExistingVariants += result.existingVariants
|
2026-06-18 10:07:58 -04:00
|
|
|
mu.Unlock()
|
|
|
|
|
}(job)
|
|
|
|
|
}
|
|
|
|
|
wg.Wait()
|
|
|
|
|
|
|
|
|
|
if len(unstarted) > 0 {
|
|
|
|
|
requeueCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
|
|
|
|
|
if err := p.jobs.RequeueClaimed(requeueCtx, unstarted, workerID); err != nil {
|
2026-07-09 20:53:52 +08:00
|
|
|
p.logger.WarnContext(ctx, "metadata image cache: failed to requeue unstarted jobs", "count", len(unstarted), "error", err)
|
2026-06-18 10:07:58 -04:00
|
|
|
}
|
|
|
|
|
cancel()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
p.cleanupSucceeded(ctx, &stats)
|
|
|
|
|
if ctxErr := ctx.Err(); ctxErr != nil && stats.Claimed == 0 {
|
|
|
|
|
return stats, ctxErr
|
|
|
|
|
}
|
|
|
|
|
return stats, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (p *ImageCacheProcessor) RunUntilIdle(ctx context.Context, workerID string, claimLimit int, concurrency int, maxRuntime time.Duration) (ImageCacheRunStats, error) {
|
|
|
|
|
var total ImageCacheRunStats
|
|
|
|
|
if p == nil || p.jobs == nil || p.cacher == nil || !p.enabled.Load() {
|
|
|
|
|
return total, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if maxRuntime <= 0 {
|
|
|
|
|
enqueued, derr := p.discoverExisting(ctx, claimLimit)
|
|
|
|
|
total.EnqueuedExisting += enqueued
|
|
|
|
|
if derr != nil {
|
|
|
|
|
return total, derr
|
|
|
|
|
}
|
|
|
|
|
stats, err := p.RunOnce(ctx, workerID, claimLimit, concurrency)
|
|
|
|
|
total.add(stats)
|
|
|
|
|
total.Batches = 1
|
|
|
|
|
return total, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Decide once per run whether a full-catalog backfill sweep is due. Within a
|
|
|
|
|
// due run we keep sweeping until the catalog is exhausted; otherwise we only
|
|
|
|
|
// drain the existing queue.
|
|
|
|
|
sweep := p.discoveryDue()
|
|
|
|
|
deadline := time.Now().Add(maxRuntime)
|
|
|
|
|
for {
|
|
|
|
|
if err := ctx.Err(); err != nil {
|
|
|
|
|
return total, err
|
|
|
|
|
}
|
|
|
|
|
if !time.Now().Before(deadline) {
|
|
|
|
|
total.RuntimeLimited = true
|
|
|
|
|
return total, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
stats, err := p.RunOnce(ctx, workerID, claimLimit, concurrency)
|
|
|
|
|
total.Batches++
|
|
|
|
|
total.add(stats)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return total, err
|
|
|
|
|
}
|
|
|
|
|
if stats.Claimed > 0 {
|
|
|
|
|
// Keep draining the queue before spending a full-table sweep.
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if !sweep {
|
|
|
|
|
return total, nil
|
|
|
|
|
}
|
|
|
|
|
enqueued, err := p.jobs.EnqueueExistingProviderArtwork(ctx, claimLimit)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return total, err
|
|
|
|
|
}
|
|
|
|
|
total.EnqueuedExisting += enqueued
|
|
|
|
|
if enqueued == 0 {
|
|
|
|
|
// Catalog fully swept; throttle the next sweep.
|
|
|
|
|
p.markDiscovered()
|
|
|
|
|
return total, nil
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// discoveryDue reports whether enough time has elapsed since the last completed
|
|
|
|
|
// sweep to run another one.
|
|
|
|
|
func (p *ImageCacheProcessor) discoveryDue() bool {
|
|
|
|
|
if p.discoveryInterval <= 0 {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
p.discoveryMu.Lock()
|
|
|
|
|
defer p.discoveryMu.Unlock()
|
|
|
|
|
return p.lastDiscovery.IsZero() || time.Since(p.lastDiscovery) >= p.discoveryInterval
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (p *ImageCacheProcessor) markDiscovered() {
|
|
|
|
|
p.discoveryMu.Lock()
|
|
|
|
|
p.lastDiscovery = time.Now()
|
|
|
|
|
p.discoveryMu.Unlock()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// discoverExisting runs an unthrottled sweep (single-pass path) and records the
|
|
|
|
|
// time so the throttle applies to subsequent interval-driven runs.
|
|
|
|
|
func (p *ImageCacheProcessor) discoverExisting(ctx context.Context, limit int) (int, error) {
|
|
|
|
|
enqueued, err := p.jobs.EnqueueExistingProviderArtwork(ctx, limit)
|
|
|
|
|
if err == nil {
|
|
|
|
|
p.markDiscovered()
|
|
|
|
|
}
|
|
|
|
|
return enqueued, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (p *ImageCacheProcessor) cleanupSucceeded(ctx context.Context, stats *ImageCacheRunStats) {
|
|
|
|
|
cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
|
|
|
|
|
defer cancel()
|
|
|
|
|
deleted, err := p.jobs.DeleteSucceededBefore(cleanupCtx, time.Now().Add(-30*24*time.Hour), 1000)
|
|
|
|
|
if err != nil {
|
2026-07-09 20:53:52 +08:00
|
|
|
p.logger.WarnContext(ctx, "metadata image cache: failed to delete old succeeded jobs", "error", err)
|
2026-06-18 10:07:58 -04:00
|
|
|
} else {
|
|
|
|
|
stats.DeletedSucceeded = deleted
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func terminalJobContext(parent context.Context) (context.Context, context.CancelFunc) {
|
|
|
|
|
return context.WithTimeout(context.WithoutCancel(parent), 10*time.Second)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (p *ImageCacheProcessor) markFailed(parent context.Context, job *models.MetadataImageCacheJob, errText string) {
|
|
|
|
|
writeCtx, cancel := terminalJobContext(parent)
|
|
|
|
|
defer cancel()
|
|
|
|
|
if err := p.jobs.MarkFailed(writeCtx, job.ID, job.AttemptCount, job.LockedBy, errText); err != nil {
|
2026-07-09 20:53:52 +08:00
|
|
|
p.logger.WarnContext(parent, "metadata image cache: failed to mark job failed", "job_id", job.ID, "error", err)
|
2026-06-18 10:07:58 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (p *ImageCacheProcessor) markSucceeded(parent context.Context, job *models.MetadataImageCacheJob) {
|
|
|
|
|
writeCtx, cancel := terminalJobContext(parent)
|
|
|
|
|
defer cancel()
|
|
|
|
|
if err := p.jobs.MarkSucceeded(writeCtx, job.ID, job.LockedBy); err != nil {
|
2026-07-09 20:53:52 +08:00
|
|
|
p.logger.WarnContext(parent, "metadata image cache: failed to mark job succeeded", "job_id", job.ID, "error", err)
|
2026-06-18 10:07:58 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-01 23:37:06 +02:00
|
|
|
type imageCacheProcessResult struct {
|
|
|
|
|
outcome string
|
|
|
|
|
uploadedVariants int
|
|
|
|
|
existingVariants int
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (p *ImageCacheProcessor) processOne(ctx context.Context, job *models.MetadataImageCacheJob) imageCacheProcessResult {
|
2026-06-18 10:07:58 -04:00
|
|
|
if job == nil {
|
2026-07-01 23:37:06 +02:00
|
|
|
return imageCacheProcessResult{outcome: "skipped"}
|
2026-06-18 10:07:58 -04:00
|
|
|
}
|
|
|
|
|
imageType, err := imageCacheJobImageType(job.ImageType)
|
|
|
|
|
if err != nil {
|
|
|
|
|
p.markFailed(ctx, job, err.Error())
|
2026-07-01 23:37:06 +02:00
|
|
|
return imageCacheProcessResult{outcome: "failed"}
|
2026-06-18 10:07:58 -04:00
|
|
|
}
|
|
|
|
|
|
2026-07-16 23:33:21 +02:00
|
|
|
// Confirm the target still references this job's source before doing the
|
|
|
|
|
// download and immutable upload. The conditional update below remains the
|
|
|
|
|
// final concurrency guard; this early check avoids work for an obsolete job.
|
2026-06-18 10:07:58 -04:00
|
|
|
current, err := p.jobs.CurrentTargetSourcePath(ctx, job)
|
|
|
|
|
if err != nil {
|
|
|
|
|
p.markFailed(ctx, job, err.Error())
|
2026-07-01 23:37:06 +02:00
|
|
|
return imageCacheProcessResult{outcome: "failed"}
|
2026-06-18 10:07:58 -04:00
|
|
|
}
|
|
|
|
|
if current != job.SourcePath {
|
|
|
|
|
p.markSucceeded(ctx, job)
|
2026-07-01 23:37:06 +02:00
|
|
|
return imageCacheProcessResult{outcome: "skipped"}
|
2026-06-18 10:07:58 -04:00
|
|
|
}
|
|
|
|
|
|
2026-07-17 05:55:36 +08:00
|
|
|
if isLocalImageSourcePath(job.SourcePath) {
|
|
|
|
|
return p.processLocalOne(ctx, job, imageType)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-18 10:07:58 -04:00
|
|
|
downloadURL := job.SourcePath
|
|
|
|
|
if isProviderImagePath(downloadURL) {
|
|
|
|
|
if p.resolver == nil {
|
|
|
|
|
p.markFailed(ctx, job, "missing image resolver")
|
2026-07-01 23:37:06 +02:00
|
|
|
return imageCacheProcessResult{outcome: "failed"}
|
2026-06-18 10:07:58 -04:00
|
|
|
}
|
|
|
|
|
downloadURL = p.resolver.ResolveImageURL(ctx, job.SourcePath, "original")
|
|
|
|
|
if downloadURL == "" {
|
|
|
|
|
p.markFailed(ctx, job, "image resolver returned empty URL")
|
2026-07-01 23:37:06 +02:00
|
|
|
return imageCacheProcessResult{outcome: "failed"}
|
2026-06-18 10:07:58 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
result, err := p.cacher.CacheImage(ctx, CacheImageRequest{
|
|
|
|
|
SourceURL: downloadURL,
|
|
|
|
|
ProviderID: job.ProviderID,
|
|
|
|
|
ContentType: job.ContentType,
|
|
|
|
|
ContentID: job.ProviderContentID,
|
|
|
|
|
ImageType: imageType,
|
|
|
|
|
SeasonNumber: job.SeasonNumber,
|
|
|
|
|
EpisodeNumber: job.EpisodeNumber,
|
|
|
|
|
Language: job.TargetLanguage,
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
p.markFailed(ctx, job, err.Error())
|
2026-07-01 23:37:06 +02:00
|
|
|
return imageCacheProcessResult{outcome: "failed"}
|
2026-06-18 10:07:58 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if result == nil {
|
|
|
|
|
p.markFailed(ctx, job, "image cache returned no result")
|
2026-07-01 23:37:06 +02:00
|
|
|
return imageCacheProcessResult{outcome: "failed"}
|
|
|
|
|
}
|
|
|
|
|
processResult := imageCacheProcessResult{
|
|
|
|
|
uploadedVariants: result.UploadedVariants,
|
|
|
|
|
existingVariants: result.ExistingVariants,
|
2026-06-18 10:07:58 -04:00
|
|
|
}
|
2026-07-16 23:33:21 +02:00
|
|
|
cachedPath := CachedImageOriginalPath(result)
|
2026-06-18 10:07:58 -04:00
|
|
|
if cachedPath == "" {
|
|
|
|
|
p.markFailed(ctx, job, "image cache returned empty stored path")
|
2026-07-01 23:37:06 +02:00
|
|
|
processResult.outcome = "failed"
|
|
|
|
|
return processResult
|
2026-06-18 10:07:58 -04:00
|
|
|
}
|
2026-07-17 05:55:36 +08:00
|
|
|
p.finishJobWithTargetUpdate(ctx, job, cachedPath, result.Thumbhash, &processResult)
|
|
|
|
|
return processResult
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// finishJobWithTargetUpdate persists the cached path/thumbhash to the job's
|
|
|
|
|
// target row and marks the job terminal, setting processResult.outcome.
|
|
|
|
|
func (p *ImageCacheProcessor) finishJobWithTargetUpdate(ctx context.Context, job *models.MetadataImageCacheJob, cachedPath, thumbhash string, processResult *imageCacheProcessResult) {
|
|
|
|
|
updated, err := p.updateTargetArtwork(ctx, job, cachedPath, thumbhash)
|
|
|
|
|
if err != nil {
|
|
|
|
|
p.markFailed(ctx, job, err.Error())
|
|
|
|
|
processResult.outcome = "failed"
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
p.markSucceeded(ctx, job)
|
|
|
|
|
if updated {
|
|
|
|
|
processResult.outcome = "succeeded"
|
|
|
|
|
} else {
|
|
|
|
|
processResult.outcome = "skipped"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (p *ImageCacheProcessor) updateTargetArtwork(ctx context.Context, job *models.MetadataImageCacheJob, cachedPath, thumbhash string) (bool, error) {
|
2026-06-18 10:07:58 -04:00
|
|
|
switch job.TargetType {
|
|
|
|
|
case ImageCacheTargetItem:
|
|
|
|
|
if p.targets.Items == nil {
|
2026-07-17 05:55:36 +08:00
|
|
|
return false, errors.New("missing item updater")
|
2026-06-18 10:07:58 -04:00
|
|
|
}
|
2026-07-17 05:55:36 +08:00
|
|
|
return p.targets.Items.UpdateArtworkIfSourceMatches(ctx, job.TargetContentID, job.ImageType, job.SourcePath, cachedPath, thumbhash)
|
2026-06-18 10:07:58 -04:00
|
|
|
case ImageCacheTargetItemLocalization:
|
|
|
|
|
if p.targets.ItemLocalizations == nil {
|
2026-07-17 05:55:36 +08:00
|
|
|
return false, errors.New("missing item localization updater")
|
2026-06-18 10:07:58 -04:00
|
|
|
}
|
2026-07-17 05:55:36 +08:00
|
|
|
return p.targets.ItemLocalizations.UpdateArtworkIfSourceMatches(ctx, job.TargetContentID, job.TargetLanguage, job.ImageType, job.SourcePath, cachedPath, thumbhash)
|
2026-06-18 10:07:58 -04:00
|
|
|
case ImageCacheTargetSeason:
|
|
|
|
|
if p.targets.Seasons == nil {
|
2026-07-17 05:55:36 +08:00
|
|
|
return false, errors.New("missing season updater")
|
2026-06-18 10:07:58 -04:00
|
|
|
}
|
2026-07-17 05:55:36 +08:00
|
|
|
return p.targets.Seasons.UpdateArtworkIfSourceMatches(ctx, job.TargetContentID, job.SourcePath, cachedPath, thumbhash)
|
2026-06-18 10:07:58 -04:00
|
|
|
case ImageCacheTargetSeasonLocalization:
|
|
|
|
|
if p.targets.SeasonLocalizations == nil {
|
2026-07-17 05:55:36 +08:00
|
|
|
return false, errors.New("missing season localization updater")
|
2026-06-18 10:07:58 -04:00
|
|
|
}
|
2026-07-17 05:55:36 +08:00
|
|
|
return p.targets.SeasonLocalizations.UpdateArtworkIfSourceMatches(ctx, job.TargetContentID, job.TargetLanguage, job.SourcePath, cachedPath, thumbhash)
|
2026-06-18 10:07:58 -04:00
|
|
|
case ImageCacheTargetEpisode:
|
|
|
|
|
if p.targets.Episodes == nil {
|
2026-07-17 05:55:36 +08:00
|
|
|
return false, errors.New("missing episode updater")
|
2026-06-18 10:07:58 -04:00
|
|
|
}
|
2026-07-17 05:55:36 +08:00
|
|
|
return p.targets.Episodes.UpdateStillIfSourceMatches(ctx, job.TargetContentID, job.SourcePath, cachedPath, thumbhash)
|
2026-06-18 10:07:58 -04:00
|
|
|
case ImageCacheTargetPerson:
|
|
|
|
|
if p.targets.People == nil {
|
2026-07-17 05:55:36 +08:00
|
|
|
return false, errors.New("missing person updater")
|
2026-06-18 10:07:58 -04:00
|
|
|
}
|
|
|
|
|
personID, parseErr := strconv.ParseInt(job.TargetContentID, 10, 64)
|
|
|
|
|
if parseErr != nil {
|
2026-07-17 05:55:36 +08:00
|
|
|
return false, fmt.Errorf("invalid person image cache target %q: %w", job.TargetContentID, parseErr)
|
2026-06-18 10:07:58 -04:00
|
|
|
}
|
2026-07-17 05:55:36 +08:00
|
|
|
return p.targets.People.UpdatePhotoIfSourceMatches(ctx, personID, job.SourcePath, cachedPath, thumbhash)
|
2026-06-18 10:07:58 -04:00
|
|
|
default:
|
2026-07-17 05:55:36 +08:00
|
|
|
return false, fmt.Errorf("unknown image cache target type %q", job.TargetType)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// processLocalOne caches a local sidecar image (file:// source). The file is
|
|
|
|
|
// confined to the owning library's roots by a lexical check on the logical path
|
|
|
|
|
// followed by a symlink-resolving re-check (both path and roots are resolved, so
|
|
|
|
|
// scanner-recorded logical paths under symlinked roots stay valid while an
|
|
|
|
|
// intermediate directory symlink escaping a root is rejected), then read through
|
|
|
|
|
// an opened handle with fstat re-checks and pushed to S3 under a content-hashed
|
|
|
|
|
// local/ key.
|
|
|
|
|
func (p *ImageCacheProcessor) processLocalOne(ctx context.Context, job *models.MetadataImageCacheJob, imageType ImageType) imageCacheProcessResult {
|
|
|
|
|
byteCacher, ok := p.cacher.(ImageByteCacher)
|
|
|
|
|
if !ok {
|
|
|
|
|
p.markFailed(ctx, job, "image cacher does not support local artwork")
|
|
|
|
|
return imageCacheProcessResult{outcome: "failed"}
|
|
|
|
|
}
|
|
|
|
|
if p.libraryRoots == nil {
|
|
|
|
|
p.markFailed(ctx, job, "missing library root resolver for local artwork")
|
|
|
|
|
return imageCacheProcessResult{outcome: "failed"}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
localPath := filepath.Clean(strings.TrimSpace(job.SourcePath)[len("file://"):])
|
|
|
|
|
rootsContentID := firstNonEmpty(job.SeriesID, job.TargetContentID)
|
|
|
|
|
roots, err := p.libraryRoots.LibraryRootsForContent(ctx, rootsContentID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
p.markFailed(ctx, job, fmt.Sprintf("resolving library roots: %v", err))
|
|
|
|
|
return imageCacheProcessResult{outcome: "failed"}
|
|
|
|
|
}
|
|
|
|
|
if !localImagePathWithinRoots(localPath, roots) {
|
|
|
|
|
p.markFailed(ctx, job, "local image path outside library roots: "+localPath)
|
|
|
|
|
return imageCacheProcessResult{outcome: "failed"}
|
2026-06-18 10:07:58 -04:00
|
|
|
}
|
2026-07-17 05:55:36 +08:00
|
|
|
// The lexical check above cannot see through symlinks. Resolve the path and
|
|
|
|
|
// roots and re-confine so an intermediate directory symlink planted inside a
|
|
|
|
|
// root (e.g. a link pointing out of the library) cannot pull an out-of-root
|
|
|
|
|
// file into the cache. EvalSymlinks resolves both sides, so a legitimately
|
|
|
|
|
// symlinked root still matches. This is a confinement GATE only: the read
|
|
|
|
|
// below still uses the logical path so readLocalImageFile's Lstat keeps
|
|
|
|
|
// rejecting a symlinked leaf. A not-yet-existent path (ErrNotExist) falls
|
|
|
|
|
// through to the reader, which classifies it as the stable "missing" failure.
|
|
|
|
|
if _, err := localImagePathResolvedWithinRoots(localPath, roots); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
|
|
|
|
p.markFailed(ctx, job, "local image path outside library roots: "+localPath)
|
|
|
|
|
return imageCacheProcessResult{outcome: "failed"}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
data, err := readLocalImageFile(localPath)
|
2026-06-18 10:07:58 -04:00
|
|
|
if err != nil {
|
|
|
|
|
p.markFailed(ctx, job, err.Error())
|
2026-07-17 05:55:36 +08:00
|
|
|
return imageCacheProcessResult{outcome: "failed"}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The target's current cached path drives the unchanged-skip (same hash →
|
|
|
|
|
// same key → nothing to sweep) and the stale-prefix cleanup below.
|
|
|
|
|
previousCachedPath := ""
|
|
|
|
|
if reader, ok := p.jobs.(imageCacheTargetCachedPathReader); ok {
|
|
|
|
|
previousCachedPath, err = reader.CurrentTargetCachedPath(ctx, job)
|
|
|
|
|
if err != nil {
|
|
|
|
|
p.logger.WarnContext(ctx, "metadata image cache: failed to read current cached path", "job_id", job.ID, "error", err)
|
|
|
|
|
previousCachedPath = ""
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
digest := sha256.Sum256(data)
|
|
|
|
|
result, err := byteCacher.CacheImageBytes(ctx, data, CacheImageRequest{
|
|
|
|
|
SourceURL: job.SourcePath,
|
|
|
|
|
ProviderID: imageCacheLocalProviderID,
|
|
|
|
|
ContentType: job.ContentType,
|
|
|
|
|
ContentID: job.ProviderContentID,
|
|
|
|
|
ImageType: imageType,
|
|
|
|
|
SeasonNumber: job.SeasonNumber,
|
|
|
|
|
EpisodeNumber: job.EpisodeNumber,
|
|
|
|
|
Language: job.TargetLanguage,
|
|
|
|
|
KeyDiscriminator: hex.EncodeToString(digest[:4]),
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
p.markFailed(ctx, job, err.Error())
|
|
|
|
|
return imageCacheProcessResult{outcome: "failed"}
|
|
|
|
|
}
|
|
|
|
|
if result == nil {
|
|
|
|
|
p.markFailed(ctx, job, "image cache returned no result")
|
|
|
|
|
return imageCacheProcessResult{outcome: "failed"}
|
|
|
|
|
}
|
|
|
|
|
processResult := imageCacheProcessResult{
|
|
|
|
|
uploadedVariants: result.UploadedVariants,
|
|
|
|
|
existingVariants: result.ExistingVariants,
|
|
|
|
|
}
|
|
|
|
|
cachedPath := CachedImageOriginalPath(result)
|
|
|
|
|
if cachedPath == "" {
|
|
|
|
|
p.markFailed(ctx, job, "image cache returned empty stored path")
|
2026-07-01 23:37:06 +02:00
|
|
|
processResult.outcome = "failed"
|
|
|
|
|
return processResult
|
2026-06-18 10:07:58 -04:00
|
|
|
}
|
2026-07-17 05:55:36 +08:00
|
|
|
p.finishJobWithTargetUpdate(ctx, job, cachedPath, result.Thumbhash, &processResult)
|
|
|
|
|
if processResult.outcome == "succeeded" {
|
|
|
|
|
p.deleteStaleLocalPrefix(ctx, previousCachedPath, cachedPath)
|
2026-06-18 10:07:58 -04:00
|
|
|
}
|
2026-07-01 23:37:06 +02:00
|
|
|
return processResult
|
2026-06-18 10:07:58 -04:00
|
|
|
}
|
|
|
|
|
|
2026-07-17 05:55:36 +08:00
|
|
|
// deleteStaleLocalPrefix removes the previous hashed local/ image prefix
|
|
|
|
|
// after a re-cache stored the artwork under a different key.
|
|
|
|
|
func (p *ImageCacheProcessor) deleteStaleLocalPrefix(ctx context.Context, previousCachedPath, cachedPath string) {
|
|
|
|
|
previousCachedPath = strings.TrimSpace(previousCachedPath)
|
|
|
|
|
if p.prefixDeleter == nil ||
|
|
|
|
|
previousCachedPath == "" ||
|
|
|
|
|
previousCachedPath == cachedPath ||
|
|
|
|
|
!strings.HasPrefix(previousCachedPath, "local/") {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
lastSlash := strings.LastIndex(previousCachedPath, "/")
|
|
|
|
|
if lastSlash <= 0 {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
prefix := previousCachedPath[:lastSlash+1]
|
|
|
|
|
if strings.HasPrefix(cachedPath, prefix) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if _, err := p.prefixDeleter.DeletePrefix(ctx, p.prefixDeleter.Bucket(), prefix); err != nil {
|
|
|
|
|
p.logger.WarnContext(ctx, "metadata image cache: failed to delete stale local image prefix", "prefix", prefix, "error", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// errLocalImageOutsideRoots is returned by localImagePathResolvedWithinRoots
|
|
|
|
|
// when a fully symlink-resolved path escapes every resolved library root.
|
|
|
|
|
var errLocalImageOutsideRoots = errors.New("local image path outside library roots")
|
|
|
|
|
|
|
|
|
|
// localImagePathResolvedWithinRoots resolves symlinks on both the path and the
|
|
|
|
|
// library roots and returns the real path when it stays within a real root.
|
|
|
|
|
// This closes the gap the lexical check leaves open — an intermediate directory
|
|
|
|
|
// symlink planted inside a root that points outside it. Resolving both sides
|
|
|
|
|
// keeps a legitimately symlinked root valid. A path that does not exist yet
|
|
|
|
|
// surfaces as fs.ErrNotExist for the caller to treat as a stable "missing"
|
|
|
|
|
// failure via the reader; any other resolution failure fails closed.
|
|
|
|
|
func localImagePathResolvedWithinRoots(path string, roots []string) (string, error) {
|
|
|
|
|
resolvedPath, err := filepath.EvalSymlinks(path)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return "", err
|
|
|
|
|
}
|
|
|
|
|
for _, root := range roots {
|
|
|
|
|
resolvedRoot, err := filepath.EvalSymlinks(strings.TrimSpace(root))
|
|
|
|
|
if err != nil {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if localImagePathWithinRoots(resolvedPath, []string{resolvedRoot}) {
|
|
|
|
|
return resolvedPath, nil
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return "", errLocalImageOutsideRoots
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// localImagePathWithinRoots confines path (already cleaned, logical) to the
|
|
|
|
|
// given library roots with a separator-aware lexical prefix check.
|
|
|
|
|
func localImagePathWithinRoots(path string, roots []string) bool {
|
|
|
|
|
if !filepath.IsAbs(path) {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
for _, root := range roots {
|
|
|
|
|
root = filepath.Clean(strings.TrimSpace(root))
|
|
|
|
|
if root == "" || root == "." || !filepath.IsAbs(root) {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if path == root {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
prefix := root
|
|
|
|
|
if prefix != string(filepath.Separator) {
|
|
|
|
|
prefix += string(filepath.Separator)
|
|
|
|
|
}
|
|
|
|
|
if strings.HasPrefix(path, prefix) {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// readLocalImageFile reads a sidecar image with the same guards as discovery:
|
|
|
|
|
// Lstat rejects symlinked leaves and non-regular files, the opened handle is
|
|
|
|
|
// fstat-re-checked, and reads cap at maxLocalImageSourceBytes. ENOENT/EPERM
|
|
|
|
|
// map to the stable-failure texts matched by isStableProviderImageFailure.
|
|
|
|
|
func readLocalImageFile(path string) ([]byte, error) {
|
|
|
|
|
classify := func(err error) error {
|
|
|
|
|
switch {
|
|
|
|
|
case errors.Is(err, fs.ErrNotExist):
|
|
|
|
|
return fmt.Errorf("local image missing: %s", path)
|
|
|
|
|
case errors.Is(err, fs.ErrPermission):
|
|
|
|
|
return fmt.Errorf("local image forbidden: %s", path)
|
|
|
|
|
default:
|
|
|
|
|
return fmt.Errorf("local image read failed: %w", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
info, err := os.Lstat(path)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, classify(err)
|
|
|
|
|
}
|
|
|
|
|
if !info.Mode().IsRegular() {
|
|
|
|
|
return nil, fmt.Errorf("local image is not a regular file: %s", path)
|
|
|
|
|
}
|
|
|
|
|
if info.Size() > maxLocalImageSourceBytes {
|
|
|
|
|
return nil, fmt.Errorf("local image exceeds %d byte limit: %s", maxLocalImageSourceBytes, path)
|
|
|
|
|
}
|
|
|
|
|
file, err := os.Open(path)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, classify(err)
|
|
|
|
|
}
|
|
|
|
|
defer func() { _ = file.Close() }()
|
|
|
|
|
stat, err := file.Stat()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, classify(err)
|
|
|
|
|
}
|
|
|
|
|
// Close the symlink-swap window: os.Open follows symlinks, so a leaf swapped
|
|
|
|
|
// to a symlink between the Lstat above and this Open would be followed to its
|
|
|
|
|
// target. Reject unless the opened handle is the exact file Lstat inspected,
|
|
|
|
|
// so an out-of-root target can never be pulled into the public cache.
|
|
|
|
|
if !os.SameFile(info, stat) {
|
|
|
|
|
return nil, fmt.Errorf("local image is not a regular file: %s", path)
|
|
|
|
|
}
|
|
|
|
|
if !stat.Mode().IsRegular() {
|
|
|
|
|
return nil, fmt.Errorf("local image is not a regular file: %s", path)
|
|
|
|
|
}
|
|
|
|
|
if stat.Size() > maxLocalImageSourceBytes {
|
|
|
|
|
return nil, fmt.Errorf("local image exceeds %d byte limit: %s", maxLocalImageSourceBytes, path)
|
|
|
|
|
}
|
|
|
|
|
data, err := io.ReadAll(io.LimitReader(file, maxLocalImageSourceBytes+1))
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, classify(err)
|
|
|
|
|
}
|
|
|
|
|
if len(data) > maxLocalImageSourceBytes {
|
|
|
|
|
return nil, fmt.Errorf("local image exceeds %d byte limit: %s", maxLocalImageSourceBytes, path)
|
|
|
|
|
}
|
|
|
|
|
if len(data) == 0 {
|
|
|
|
|
return nil, fmt.Errorf("local image read failed: empty file %s", path)
|
|
|
|
|
}
|
|
|
|
|
return data, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-18 10:07:58 -04:00
|
|
|
func imageCacheJobImageType(value string) (ImageType, error) {
|
|
|
|
|
switch value {
|
|
|
|
|
case ImageCacheImagePoster:
|
|
|
|
|
return ImagePoster, nil
|
|
|
|
|
case ImageCacheImageBackdrop:
|
|
|
|
|
return ImageBackdrop, nil
|
|
|
|
|
case ImageCacheImageLogo:
|
|
|
|
|
return ImageLogo, nil
|
|
|
|
|
case ImageCacheImageStill:
|
|
|
|
|
return ImageStill, nil
|
|
|
|
|
case ImageCacheImageProfile:
|
|
|
|
|
return ImageProfile, nil
|
|
|
|
|
default:
|
|
|
|
|
return ImagePoster, fmt.Errorf("unknown metadata image cache image type %q", value)
|
|
|
|
|
}
|
|
|
|
|
}
|