Files
silo-server/internal/api/handlers/admin_images.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

470 lines
15 KiB
Go

package handlers
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/Silo-Server/silo-server/internal/catalog"
evt "github.com/Silo-Server/silo-server/internal/events"
"github.com/Silo-Server/silo-server/internal/metadata"
"github.com/Silo-Server/silo-server/internal/models"
)
// ImageService defines the image-related methods on MetadataService
// needed by the admin image handler.
type ImageService interface {
FetchItemImages(ctx context.Context, providerIDs map[string]string, contentType string, language string, folderID int) ([]metadata.RemoteImage, map[string]string, error)
ApplyItemImage(ctx context.Context, req metadata.ApplyItemImageRequest) (*metadata.ApplyItemImageResult, error)
}
// ImageItemLookup loads media items, seasons, and episodes by content ID.
type ImageItemLookup interface {
GetByID(ctx context.Context, contentID string) (*models.MediaItem, error)
}
// ImageSeasonLookup loads seasons by content ID.
type ImageSeasonLookup interface {
GetByID(ctx context.Context, contentID string) (*models.Season, error)
}
// ImageEpisodeLookup loads episodes by content ID.
type ImageEpisodeLookup interface {
GetByID(ctx context.Context, contentID string) (*models.Episode, error)
}
// ImageURLResolver resolves image URLs for display (plugin-prefixed → HTTP).
type ImageURLResolver interface {
ResolveImageURL(ctx context.Context, path string, variant string) string
ResolveImageURLs(ctx context.Context, paths []string, variant string) map[string]string
}
// AdminImageHandler handles endpoints for browsing and selecting item images.
type AdminImageHandler struct {
items ImageItemLookup
seasons ImageSeasonLookup
episodes ImageEpisodeLookup
folders MatchFolderLookup
imageSvc ImageService
imageResolver ImageURLResolver
detailSvc *catalog.DetailService
EventsHub *evt.Hub
}
// NewAdminImageHandler creates a handler for admin image selection endpoints.
func NewAdminImageHandler(
items ImageItemLookup,
seasons ImageSeasonLookup,
episodes ImageEpisodeLookup,
folders MatchFolderLookup,
imageSvc ImageService,
imageResolver ImageURLResolver,
detailSvc *catalog.DetailService,
) *AdminImageHandler {
return &AdminImageHandler{
items: items,
seasons: seasons,
episodes: episodes,
folders: folders,
imageSvc: imageSvc,
imageResolver: imageResolver,
detailSvc: detailSvc,
}
}
// --- Request/Response types ---
type itemImageEntry struct {
ProviderID string `json:"provider_id"`
URL string `json:"url"`
OriginalURL string `json:"original_url"`
Type string `json:"type"`
Language string `json:"language"`
Width int `json:"width"`
Height int `json:"height"`
Rating float64 `json:"rating"`
}
type getItemImagesResponse struct {
Images []itemImageEntry `json:"images"`
Current currentImages `json:"current"`
ProviderErrors map[string]string `json:"provider_errors,omitempty"`
}
type currentImages struct {
PosterURL string `json:"poster_url,omitempty"`
BackdropURL string `json:"backdrop_url,omitempty"`
LogoURL string `json:"logo_url,omitempty"`
}
type applyItemImageRequest struct {
OriginalURL string `json:"original_url"`
Type string `json:"type"`
ProviderID string `json:"provider_id"`
}
type applyItemImageResponse struct {
ContentID string `json:"content_id"`
StoredPath string `json:"stored_path"`
Thumbhash string `json:"thumbhash"`
ImageURL string `json:"image_url,omitempty"`
Revision string `json:"revision,omitempty"`
}
// resolvedItem holds the result of looking up a content ID across all three tables.
type resolvedItem struct {
contentType string // "movie", "series", "season", "episode"
// The parent MediaItem (for movies/series it's the item itself;
// for seasons/episodes it's the owning series).
parentItem *models.MediaItem
// The season/episode model, if applicable.
season *models.Season
episode *models.Episode
}
// resolveContentID looks up a content ID in media_items, then seasons, then episodes.
// Returns the resolved item info including the parent MediaItem (for provider IDs).
func (h *AdminImageHandler) resolveContentID(ctx context.Context, contentID string) (*resolvedItem, error) {
// Try media_items first.
item, err := h.items.GetByID(ctx, contentID)
if err == nil {
return &resolvedItem{
contentType: item.Type,
parentItem: item,
}, nil
}
if !errors.Is(err, catalog.ErrItemNotFound) {
return nil, err
}
// Try seasons.
season, err := h.seasons.GetByID(ctx, contentID)
if err == nil {
// Load the parent series for provider IDs.
parentItem, err := h.items.GetByID(ctx, season.SeriesID)
if err != nil {
return nil, err
}
return &resolvedItem{
contentType: "season",
parentItem: parentItem,
season: season,
}, nil
}
if !errors.Is(err, catalog.ErrSeasonNotFound) {
return nil, err
}
// Try episodes.
ep, err := h.episodes.GetByID(ctx, contentID)
if err == nil {
parentItem, err := h.items.GetByID(ctx, ep.SeriesID)
if err != nil {
return nil, err
}
return &resolvedItem{
contentType: "episode",
parentItem: parentItem,
episode: ep,
}, nil
}
if !errors.Is(err, catalog.ErrEpisodeNotFound) {
return nil, err
}
return nil, catalog.ErrItemNotFound
}
// HandleGetItemImages handles GET /admin/items/{id}/images.
// It fetches available images from all enabled metadata providers.
func (h *AdminImageHandler) HandleGetItemImages(w http.ResponseWriter, r *http.Request) {
contentID := chi.URLParam(r, "id")
if contentID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Item ID is required")
return
}
resolved, err := h.resolveContentID(r.Context(), contentID)
if err != nil {
if errors.Is(err, catalog.ErrItemNotFound) {
writeError(w, http.StatusNotFound, "not_found", "Item not found")
return
}
slog.ErrorContext(r.Context(), "admin images: resolve content ID failed", "component", "api", "content_id", contentID, "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to resolve item")
return
}
// Determine the folder ID for chain resolution.
// For seasons/episodes, use the parent series content ID.
lookupContentID := resolved.parentItem.ContentID
folderID, err := h.resolveImageFolderID(r.Context(), lookupContentID)
if err != nil {
slog.ErrorContext(r.Context(), "admin images: resolve folder failed", "component", "api", "content_id", contentID, "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Could not determine library for item")
return
}
// Build provider IDs map from the parent item.
providerIDs := buildProviderIDs(resolved.parentItem)
// Determine the language preference. Use the parent item's default
// metadata language, or fall back to "en".
language := resolved.parentItem.DefaultMetadataLanguage
if language == "" {
language = "en"
}
// Use the parent item's type for the plugin call (always "movie" or "series").
images, providerErrors, err := h.imageSvc.FetchItemImages(
r.Context(), providerIDs, resolved.parentItem.Type, language, folderID,
)
if err != nil {
slog.ErrorContext(r.Context(), "admin images: fetch failed", "component", "api", "content_id", contentID, "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch images")
return
}
// Batch-resolve plugin-prefixed URLs for display.
rawPaths := make([]string, len(images))
for i, img := range images {
rawPaths[i] = img.URL
}
var resolvedURLs map[string]string
if h.imageResolver != nil && len(rawPaths) > 0 {
resolvedURLs = h.imageResolver.ResolveImageURLs(r.Context(), rawPaths, "card")
}
// Build the response entries.
entries := make([]itemImageEntry, 0, len(images))
for _, img := range images {
displayURL := img.URL
if resolved, ok := resolvedURLs[img.URL]; ok && resolved != "" {
displayURL = resolved
}
entries = append(entries, itemImageEntry{
ProviderID: img.ProviderID,
URL: displayURL,
OriginalURL: img.URL,
Type: metadata.ImageTypeToString(img.Type),
Language: img.Language,
Width: img.Width,
Height: img.Height,
Rating: img.Rating,
})
}
// Include current image paths so the frontend can highlight them.
current := currentImages{
PosterURL: resolved.parentItem.PosterPath,
BackdropURL: resolved.parentItem.BackdropPath,
LogoURL: resolved.parentItem.LogoPath,
}
// For seasons, use the season's poster if it has one.
if resolved.season != nil && resolved.season.PosterPath != "" {
current.PosterURL = resolved.season.PosterPath
}
writeJSON(w, http.StatusOK, getItemImagesResponse{
Images: entries,
Current: current,
ProviderErrors: providerErrors,
})
}
// HandleApplyItemImage handles POST /admin/items/{id}/images/apply.
// It downloads the selected image, caches it to S3, and updates the item.
func (h *AdminImageHandler) HandleApplyItemImage(w http.ResponseWriter, r *http.Request) {
contentID := chi.URLParam(r, "id")
if contentID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Item ID is required")
return
}
var req applyItemImageRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
if req.OriginalURL == "" || req.Type == "" {
writeError(w, http.StatusBadRequest, "bad_request", "original_url and type are required")
return
}
resolved, err := h.resolveContentID(r.Context(), contentID)
if err != nil {
if errors.Is(err, catalog.ErrItemNotFound) {
writeError(w, http.StatusNotFound, "not_found", "Item not found")
return
}
slog.ErrorContext(r.Context(), "admin images: resolve content ID failed", "component", "api", "content_id", contentID, "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to resolve item")
return
}
imageType := metadata.ImageTypeFromString(req.Type)
// Episodes only have stills. Clients historically sent "poster" here (the
// old flow silently dropped it), so coerce rather than reject.
if resolved.contentType == "episode" {
imageType = metadata.ImageStill
}
// Reject unsupported target/image combinations before spending a download
// and an S3 upload on an image that can never be published.
if err := catalog.ValidateArtworkSelectionTarget(resolved.contentType, metadata.ImageTypeToString(imageType)); err != nil {
writeError(w, http.StatusBadRequest, "unsupported_image_type",
fmt.Sprintf("%s items do not accept %s images", resolved.contentType, metadata.ImageTypeToString(imageType)))
return
}
providerID := req.ProviderID
if providerID == "" {
providerID = primaryProvider(resolved.parentItem)
}
// Use the parent item's ContentID for S3 key construction. Season /
// episode numbers (when present) scope the S3 key beneath the series
// prefix so siblings do not collide.
cacheContentID := findBestContentID(resolved.parentItem, providerID)
var seasonNumber, episodeNumber *int
switch resolved.contentType {
case "season":
if resolved.season != nil {
n := resolved.season.SeasonNumber
seasonNumber = &n
}
case "episode":
if resolved.episode != nil {
s := resolved.episode.SeasonNumber
e := resolved.episode.EpisodeNumber
seasonNumber = &s
episodeNumber = &e
}
}
result, err := h.imageSvc.ApplyItemImage(r.Context(), metadata.ApplyItemImageRequest{
OriginalURL: req.OriginalURL,
ProviderID: providerID,
ContentType: resolved.parentItem.Type,
ContentID: cacheContentID,
ImageType: imageType,
SeasonNumber: seasonNumber,
EpisodeNumber: episodeNumber,
})
if err != nil {
slog.ErrorContext(r.Context(), "admin images: apply failed", "component", "api", "content_id", contentID, "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to apply image")
return
}
err = h.detailSvc.PublishArtworkSelection(r.Context(), catalog.ArtworkSelection{
TargetType: resolved.contentType,
TargetContentID: contentID,
ParentContentID: resolved.parentItem.ContentID,
ImageType: metadata.ImageTypeToString(imageType),
StoredPath: result.StoredPath,
SourcePath: req.OriginalURL,
Thumbhash: result.Thumbhash,
LockField: int(metadata.FieldImages),
})
if err != nil {
slog.ErrorContext(r.Context(), "admin images: persist failed", "component", "api", "content_id", contentID, "error", err)
if cleanupErr := h.detailSvc.QueueArtworkRevisionGC(
r.Context(), result.StoredPath, metadata.ImageTypeToString(imageType), time.Now().Add(time.Hour),
); cleanupErr != nil {
slog.ErrorContext(r.Context(), "admin images: failed to queue unpublished artwork cleanup", "component", "api",
"content_id", contentID, "stored_path", result.StoredPath, "error", cleanupErr)
}
switch {
case errors.Is(err, catalog.ErrItemNotFound),
errors.Is(err, catalog.ErrSeasonNotFound),
errors.Is(err, catalog.ErrEpisodeNotFound):
writeError(w, http.StatusNotFound, "not_found", "Item not found")
default:
writeError(w, http.StatusInternalServerError, "internal_error", "Image cached but failed to update item")
}
return
}
publishEventMetadataUpdate(r.Context(), h.EventsHub, 0, contentID)
if resolved.parentItem.ContentID != contentID {
publishEventMetadataUpdate(r.Context(), h.EventsHub, 0, resolved.parentItem.ContentID)
}
imageURL := ""
if h.imageResolver != nil {
imageURL = h.imageResolver.ResolveImageURL(r.Context(), result.StoredPath, "original")
}
writeJSON(w, http.StatusOK, applyItemImageResponse{
ContentID: contentID,
StoredPath: result.StoredPath,
Thumbhash: result.Thumbhash,
ImageURL: imageURL,
Revision: result.Revision,
})
}
// resolveImageFolderID finds the primary library folder for a content ID.
func (h *AdminImageHandler) resolveImageFolderID(ctx context.Context, contentID string) (int, error) {
if h.folders == nil {
return 0, nil
}
folderID, err := h.folders.GetFolderIDForItem(ctx, contentID)
if err != nil {
return 0, fmt.Errorf("resolving folder for %s: %w", contentID, err)
}
return folderID, nil
}
// buildProviderIDs extracts the provider IDs map from a MediaItem.
func buildProviderIDs(item *models.MediaItem) map[string]string {
ids := make(map[string]string)
if item.TmdbID != "" {
ids["tmdb"] = item.TmdbID
}
if item.TvdbID != "" {
ids["tvdb"] = item.TvdbID
}
if item.ImdbID != "" {
ids["imdb"] = item.ImdbID
}
return ids
}
// primaryProvider returns the primary provider slug for a media item.
func primaryProvider(item *models.MediaItem) string {
if item.TmdbID != "" {
return "tmdb"
}
if item.TvdbID != "" {
return "tvdb"
}
return ""
}
// findBestContentID returns the best provider-specific ID for S3 key construction.
func findBestContentID(item *models.MediaItem, providerID string) string {
switch providerID {
case "tmdb":
if item.TmdbID != "" {
return item.TmdbID
}
case "tvdb":
if item.TvdbID != "" {
return item.TvdbID
}
}
if item.TmdbID != "" {
return item.TmdbID
}
if item.TvdbID != "" {
return item.TvdbID
}
return item.ContentID
}