483 lines
15 KiB
Go
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
|
|
}
|