Files
silo-server/internal/api/handlers/admin_images.go
T

483 lines
15 KiB
Go

package handlers
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/Silo-Server/silo-server/internal/catalog"
"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
}
// 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"`
}
// 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.Error("admin images: resolve content ID failed", "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.Error("admin images: resolve folder failed", "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.Error("admin images: fetch failed", "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.Error("admin images: resolve content ID failed", "content_id", contentID, "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to resolve item")
return
}
imageType := metadata.ImageTypeFromString(req.Type)
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.Error("admin images: apply failed", "content_id", contentID, "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to apply image")
return
}
// Build the MetadataUpdate targeting only the relevant image field.
upd := buildImageUpdate(imageType, result.StoredPath, result.Thumbhash)
// Lock FieldImages on the parent media_items row to prevent
// automatic refreshes from overwriting the manual selection.
if resolved.contentType == "movie" || resolved.contentType == "series" {
locked := mergeLockedField(resolved.parentItem.LockedFields, int(metadata.FieldImages))
upd.LockedFields = &locked
}
// Persist to the correct table.
if err := h.persistImageUpdate(r.Context(), contentID, resolved, &upd); err != nil {
slog.Error("admin images: persist failed", "content_id", contentID, "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Image cached but failed to update item")
return
}
// Also lock FieldImages on the parent series for seasons/episodes.
if resolved.contentType == "season" || resolved.contentType == "episode" {
parentLocked := mergeLockedField(resolved.parentItem.LockedFields, int(metadata.FieldImages))
parentUpd := catalog.MetadataUpdate{LockedFields: &parentLocked}
if lockErr := h.detailSvc.UpdateMediaItemMetadata(r.Context(), resolved.parentItem.ContentID, &parentUpd); lockErr != nil {
slog.Error("admin images: failed to lock FieldImages on parent series",
"content_id", contentID, "parent_id", resolved.parentItem.ContentID, "error", lockErr)
}
}
writeJSON(w, http.StatusOK, applyItemImageResponse{
ContentID: contentID,
StoredPath: result.StoredPath,
Thumbhash: result.Thumbhash,
})
}
// persistImageUpdate writes the image path update to the correct table.
func (h *AdminImageHandler) persistImageUpdate(ctx context.Context, contentID string, resolved *resolvedItem, upd *catalog.MetadataUpdate) error {
switch resolved.contentType {
case "season":
return h.detailSvc.UpdateSeasonMetadata(ctx, contentID, upd)
case "episode":
return h.detailSvc.UpdateEpisodeMetadata(ctx, contentID, upd)
default:
return h.detailSvc.UpdateMediaItemMetadata(ctx, contentID, upd)
}
}
// 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
}
// buildImageUpdate constructs a MetadataUpdate targeting a single image field.
func buildImageUpdate(imageType metadata.ImageType, storedPath, thumbhash string) catalog.MetadataUpdate {
var upd catalog.MetadataUpdate
switch imageType {
case metadata.ImageBackdrop:
upd.BackdropPath = &storedPath
upd.BackdropThumbhash = &thumbhash
case metadata.ImageLogo:
upd.LogoPath = &storedPath
case metadata.ImageStill:
upd.StillPath = &storedPath
upd.StillThumbhash = &thumbhash
default: // poster
upd.PosterPath = &storedPath
upd.PosterThumbhash = &thumbhash
}
return upd
}
// mergeLockedField adds a field value to the locked fields slice if not already present.
func mergeLockedField(existing []int, field int) []int {
for _, f := range existing {
if f == field {
return existing
}
}
result := make([]int, len(existing)+1)
copy(result, existing)
result[len(existing)] = field
return result
}
// 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
}