3716 lines
127 KiB
Go
3716 lines
127 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"net/http"
|
|
"runtime/debug"
|
|
"slices"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"golang.org/x/sync/errgroup"
|
|
|
|
"log/slog"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/access"
|
|
"github.com/Silo-Server/silo-server/internal/adminjob"
|
|
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
|
|
"github.com/Silo-Server/silo-server/internal/catalog"
|
|
"github.com/Silo-Server/silo-server/internal/collage"
|
|
"github.com/Silo-Server/silo-server/internal/collections/templates"
|
|
evt "github.com/Silo-Server/silo-server/internal/events"
|
|
"github.com/Silo-Server/silo-server/internal/models"
|
|
"github.com/Silo-Server/silo-server/internal/s3client"
|
|
"github.com/Silo-Server/silo-server/internal/sections"
|
|
"github.com/Silo-Server/silo-server/internal/usercollections"
|
|
)
|
|
|
|
type LibraryCollectionHandler struct {
|
|
repo *catalog.LibraryCollectionRepository
|
|
service *catalog.LibraryCollectionService
|
|
itemRepo *catalog.ItemRepository
|
|
Executor *catalog.QueryExecutor
|
|
detailSvc *catalog.DetailService
|
|
presignTTL time.Duration
|
|
httpClient *http.Client
|
|
s3GP *s3client.Client
|
|
FrontendFS fs.FS
|
|
SectionRepo *sections.Repository
|
|
UserCollectionPool *pgxpool.Pool
|
|
GroupRepo *catalog.LibraryCollectionGroupRepository
|
|
FolderRepo *catalog.FolderRepository
|
|
TemplateRegistry *templates.Registry
|
|
SmartCountRefresher *catalog.SmartCountRefresher
|
|
JobRepo *adminjob.Repository
|
|
EventsHub *evt.Hub
|
|
}
|
|
|
|
var errLibraryCollectionInUse = errors.New("collection is used by one or more sections")
|
|
|
|
const (
|
|
templateBundleSyncConcurrency = 4
|
|
templateBundleInlineSyncLimit = 20
|
|
templateBundleCollageConcurrency = 2
|
|
templateBundleCollageTimeout = 5 * time.Minute
|
|
templateBundleAsyncSyncTimeout = 30 * time.Minute
|
|
|
|
collectionManagementModeManual = "manual"
|
|
collectionManagementModeSection = "section"
|
|
collectionManagementModeTemplateBundle = "template_bundle"
|
|
|
|
// collectionSourceModeTMDBCollection is the `source_config.mode` value used
|
|
// for TMDB-franchise collections (curated `/collection/{id}` results).
|
|
collectionSourceModeTMDBCollection = "tmdb_collection"
|
|
|
|
// collectionSourceModeTMDBDiscover is the `source_config.mode` value used
|
|
// for TMDB discover collections (genre matrices, decade slices, etc.).
|
|
collectionSourceModeTMDBDiscover = "tmdb_discover"
|
|
)
|
|
|
|
func NewLibraryCollectionHandler(
|
|
repo *catalog.LibraryCollectionRepository,
|
|
service *catalog.LibraryCollectionService,
|
|
itemRepo *catalog.ItemRepository,
|
|
presignTTL time.Duration,
|
|
httpClient *http.Client,
|
|
s3GP *s3client.Client,
|
|
) *LibraryCollectionHandler {
|
|
if httpClient == nil {
|
|
httpClient = http.DefaultClient
|
|
}
|
|
|
|
return &LibraryCollectionHandler{
|
|
repo: repo,
|
|
service: service,
|
|
itemRepo: itemRepo,
|
|
presignTTL: presignTTL,
|
|
httpClient: httpClient,
|
|
s3GP: s3GP,
|
|
TemplateRegistry: templates.Default,
|
|
}
|
|
}
|
|
|
|
// SetDetailService configures the detail service for image URL resolution.
|
|
func (h *LibraryCollectionHandler) SetDetailService(svc *catalog.DetailService) {
|
|
h.detailSvc = svc
|
|
}
|
|
|
|
// SetupCollage enables automatic poster collage generation for collections.
|
|
// Must be called after SetDetailService.
|
|
func (h *LibraryCollectionHandler) SetupCollage() {
|
|
if h.s3GP == nil || h.detailSvc == nil {
|
|
slog.Info("collage: auto-generation disabled", "s3_configured", h.s3GP != nil, "detail_svc_configured", h.detailSvc != nil)
|
|
return
|
|
}
|
|
h.service.CollageGen = h
|
|
slog.Info("collage: auto-generation enabled")
|
|
}
|
|
|
|
func (h *LibraryCollectionHandler) storeBundledTemplatePoster(
|
|
ctx context.Context,
|
|
collectionID, posterPath string,
|
|
fromTemplate bool,
|
|
) (bool, string, string, error) {
|
|
storedPath, thumbhash, stored, err := storeBundledCollectionPosterIfS3Configured(
|
|
ctx,
|
|
h.s3GP,
|
|
h.FrontendFS,
|
|
collectionID,
|
|
adminCollectionImagePrefix,
|
|
posterPath,
|
|
)
|
|
if err != nil || !stored {
|
|
if err != nil {
|
|
slog.Warn("failed to store bundled template poster",
|
|
"collection_id", collectionID,
|
|
"poster_path", posterPath,
|
|
"error", err,
|
|
)
|
|
}
|
|
return false, storedPath, thumbhash, nil
|
|
}
|
|
|
|
notAutoGenerated := false
|
|
input := catalog.UpdateLibraryCollectionInput{
|
|
ID: collectionID,
|
|
PosterURL: &storedPath,
|
|
PosterThumbhash: &thumbhash,
|
|
PosterAutoGenerated: ¬AutoGenerated,
|
|
}
|
|
if fromTemplate {
|
|
input.PosterFromTemplate = &fromTemplate
|
|
}
|
|
if err := h.repo.Update(ctx, input); err != nil {
|
|
slog.Warn("failed to persist bundled template poster",
|
|
"collection_id", collectionID,
|
|
"poster_path", posterPath,
|
|
"stored_path", storedPath,
|
|
"error", err,
|
|
)
|
|
return false, "", "", nil
|
|
}
|
|
return true, storedPath, thumbhash, nil
|
|
}
|
|
|
|
// GenerateCollectionPoster implements catalog.CollageGenerator.
|
|
// It fetches item poster images, composes a collage, and stores the result.
|
|
func (h *LibraryCollectionHandler) GenerateCollectionPoster(ctx context.Context, collectionID string) error {
|
|
const maxItems = 4
|
|
|
|
paths, err := h.repo.ListItemPosterPaths(ctx, collectionID, maxItems)
|
|
if err != nil {
|
|
return fmt.Errorf("listing item poster paths: %w", err)
|
|
}
|
|
if len(paths) == 0 {
|
|
return collage.ErrNotEnoughImages
|
|
}
|
|
|
|
slog.Info("collage: generating poster", "collection_id", collectionID, "item_poster_count", len(paths))
|
|
|
|
// Resolve poster paths to fetchable URLs.
|
|
resolved := h.detailSvc.PresignImageURLs(ctx, paths, "poster", "small")
|
|
|
|
// Fetch each image.
|
|
imageData := make([][]byte, 0, len(paths))
|
|
fetchFailed := 0
|
|
for _, path := range paths {
|
|
url := resolved[path]
|
|
if url == "" {
|
|
fetchFailed++
|
|
continue
|
|
}
|
|
data, err := h.fetchImageURL(ctx, url)
|
|
if err != nil {
|
|
slog.Debug("collage: failed to fetch poster image", "url", url, "error", err)
|
|
fetchFailed++
|
|
continue
|
|
}
|
|
imageData = append(imageData, data)
|
|
}
|
|
if fetchFailed > 0 && len(imageData) == 0 {
|
|
slog.Warn("collage: all poster image fetches failed",
|
|
"collection_id", collectionID, "total", len(paths), "failed", fetchFailed)
|
|
}
|
|
|
|
// Compose the collage.
|
|
composited, err := collage.ComposePoster(imageData)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Delete any existing auto-generated images before uploading new ones.
|
|
if err := h.deleteCollectionImages(ctx, collectionID, "poster"); err != nil {
|
|
slog.Warn("collage: failed to clean up old poster images", "collection_id", collectionID, "error", err)
|
|
}
|
|
|
|
// Process through the standard image pipeline (generates WebP variants + thumbhash).
|
|
s3Path, thumbhash, err := h.processCollectionImage(ctx, collectionID, "poster", composited)
|
|
if err != nil {
|
|
return fmt.Errorf("processing collage image: %w", err)
|
|
}
|
|
|
|
autoGen := true
|
|
notFromTemplate := false
|
|
if err := h.repo.Update(ctx, catalog.UpdateLibraryCollectionInput{
|
|
ID: collectionID,
|
|
PosterURL: &s3Path,
|
|
PosterThumbhash: &thumbhash,
|
|
PosterAutoGenerated: &autoGen,
|
|
PosterFromTemplate: ¬FromTemplate,
|
|
}); err != nil {
|
|
return fmt.Errorf("updating collection poster: %w", err)
|
|
}
|
|
|
|
slog.Info("collage: poster generated successfully", "collection_id", collectionID, "s3_path", s3Path)
|
|
return nil
|
|
}
|
|
|
|
// fetchImageURL downloads an image from a resolved URL.
|
|
func (h *LibraryCollectionHandler) fetchImageURL(ctx context.Context, imageURL string) ([]byte, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, imageURL, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
client := h.httpClient
|
|
if client == nil {
|
|
client = http.DefaultClient
|
|
}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("image fetch returned status %d", resp.StatusCode)
|
|
}
|
|
return io.ReadAll(io.LimitReader(resp.Body, collectionImageMaxBytes))
|
|
}
|
|
|
|
type libraryCollectionResponse struct {
|
|
ID string `json:"id"`
|
|
LibraryID int `json:"library_id"`
|
|
LibraryIDs []int `json:"library_ids"`
|
|
Slug string `json:"slug"`
|
|
Title string `json:"title"`
|
|
Description string `json:"description"`
|
|
CollectionType string `json:"collection_type"`
|
|
Visibility string `json:"visibility"`
|
|
SortOrder int `json:"sort_order"`
|
|
GroupID *string `json:"group_id"`
|
|
Featured bool `json:"featured"`
|
|
PosterURL string `json:"poster_url"`
|
|
BackdropURL string `json:"backdrop_url"`
|
|
PosterThumbhash string `json:"poster_thumbhash,omitempty"`
|
|
BackdropThumbhash string `json:"backdrop_thumbhash,omitempty"`
|
|
SourceURL string `json:"source_url"`
|
|
QueryDefinition json.RawMessage `json:"query_definition"`
|
|
SortConfig json.RawMessage `json:"sort_config"`
|
|
SourceConfig json.RawMessage `json:"source_config"`
|
|
ManagementMode string `json:"management_mode"`
|
|
ManagementSource string `json:"management_source"`
|
|
ManagementKey string `json:"management_key"`
|
|
LastSyncStatus string `json:"last_sync_status"`
|
|
LastSyncMessage string `json:"last_sync_message"`
|
|
LastSyncAt string `json:"last_sync_at,omitempty"`
|
|
SyncSchedule string `json:"sync_schedule,omitempty"`
|
|
NextSyncAt string `json:"next_sync_at,omitempty"`
|
|
ItemCount int `json:"item_count"`
|
|
CreatedAt string `json:"created_at"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
}
|
|
|
|
type libraryCollectionGroupResponse struct {
|
|
ID string `json:"id"`
|
|
LibraryID int `json:"library_id"`
|
|
Name string `json:"name"`
|
|
Slug string `json:"slug"`
|
|
Kind string `json:"kind"`
|
|
DefaultSortMode string `json:"default_sort_mode"`
|
|
SortOrder int `json:"sort_order"`
|
|
}
|
|
|
|
type libraryCollectionGroupsListResponse struct {
|
|
Groups []libraryCollectionGroupResponse `json:"groups"`
|
|
UngroupedSortOrder int `json:"ungrouped_sort_order"`
|
|
}
|
|
|
|
type createLibraryCollectionGroupRequest struct {
|
|
LibraryID int `json:"library_id"`
|
|
Name string `json:"name"`
|
|
Slug string `json:"slug"`
|
|
DefaultSortMode string `json:"default_sort_mode"`
|
|
}
|
|
|
|
type updateLibraryCollectionGroupRequest struct {
|
|
Name *string `json:"name"`
|
|
Slug *string `json:"slug"`
|
|
DefaultSortMode *string `json:"default_sort_mode"`
|
|
}
|
|
|
|
type reorderLibraryCollectionGroupsRequest struct {
|
|
LibraryID int `json:"library_id"`
|
|
OrderedIDs []string `json:"ordered_ids"`
|
|
}
|
|
|
|
type libraryCollectionsListResponse struct {
|
|
Collections []libraryCollectionResponse `json:"collections"`
|
|
Groups []libraryCollectionGroupResponse `json:"groups,omitempty"`
|
|
}
|
|
|
|
type createLibraryCollectionRequest struct {
|
|
LibraryID int `json:"library_id"`
|
|
LibraryIDs []int `json:"library_ids"`
|
|
Slug string `json:"slug"`
|
|
Title string `json:"title"`
|
|
Description string `json:"description"`
|
|
CollectionType string `json:"collection_type"`
|
|
Visibility string `json:"visibility"`
|
|
SortOrder int `json:"sort_order"`
|
|
GroupID *string `json:"group_id"`
|
|
Featured bool `json:"featured"`
|
|
PosterURL string `json:"poster_url"`
|
|
BackdropURL string `json:"backdrop_url"`
|
|
PosterSourceURL string `json:"poster_source_url"`
|
|
BackdropSourceURL string `json:"backdrop_source_url"`
|
|
SourceURL string `json:"source_url"`
|
|
QueryDefinition json.RawMessage `json:"query_definition"`
|
|
SortConfig json.RawMessage `json:"sort_config"`
|
|
SourceConfig json.RawMessage `json:"source_config"`
|
|
ManagementMode string `json:"management_mode"`
|
|
ManagementSource string `json:"management_source"`
|
|
ManagementKey string `json:"management_key"`
|
|
SyncSchedule string `json:"sync_schedule"`
|
|
}
|
|
|
|
type updateLibraryCollectionRequest struct {
|
|
LibraryIDs *[]int `json:"library_ids"`
|
|
Slug *string `json:"slug"`
|
|
Title *string `json:"title"`
|
|
Description *string `json:"description"`
|
|
CollectionType *string `json:"collection_type"`
|
|
Visibility *string `json:"visibility"`
|
|
SortOrder *int `json:"sort_order"`
|
|
GroupID optionalNullableString `json:"group_id"`
|
|
Featured *bool `json:"featured"`
|
|
PosterURL *string `json:"poster_url"`
|
|
BackdropURL *string `json:"backdrop_url"`
|
|
PosterSourceURL *string `json:"poster_source_url"`
|
|
BackdropSourceURL *string `json:"backdrop_source_url"`
|
|
SourceURL *string `json:"source_url"`
|
|
QueryDefinition json.RawMessage `json:"query_definition"`
|
|
SortConfig json.RawMessage `json:"sort_config"`
|
|
SourceConfig json.RawMessage `json:"source_config"`
|
|
ManagementMode *string `json:"management_mode"`
|
|
ManagementSource *string `json:"management_source"`
|
|
ManagementKey *string `json:"management_key"`
|
|
SyncSchedule *string `json:"sync_schedule"`
|
|
}
|
|
|
|
type importMDBListRequest struct {
|
|
LibraryID int `json:"library_id"`
|
|
LibraryIDs []int `json:"library_ids"`
|
|
Title string `json:"title"`
|
|
Description string `json:"description"`
|
|
URL string `json:"url"`
|
|
Limit *int `json:"limit,omitempty"`
|
|
Featured bool `json:"featured"`
|
|
SortOrder int `json:"sort_order,omitempty"`
|
|
PosterURL string `json:"poster_url"`
|
|
PosterSourceURL string `json:"poster_source_url"`
|
|
BackdropSourceURL string `json:"backdrop_source_url"`
|
|
SyncSchedule string `json:"sync_schedule"`
|
|
ManagementMode string `json:"management_mode,omitempty"`
|
|
ManagementSource string `json:"management_source,omitempty"`
|
|
ManagementKey string `json:"management_key,omitempty"`
|
|
// PosterFromTemplate marks the poster as supplied by a template bundle so
|
|
// future template applies can refresh it. Set programmatically by
|
|
// createCollectionFromTemplate; not user-settable via JSON.
|
|
PosterFromTemplate bool `json:"-"`
|
|
}
|
|
|
|
type importTMDBRequest struct {
|
|
LibraryID int `json:"library_id"`
|
|
LibraryIDs []int `json:"library_ids"`
|
|
Title string `json:"title"`
|
|
Description string `json:"description"`
|
|
Preset string `json:"preset"`
|
|
TimeWindow string `json:"time_window"`
|
|
MediaType string `json:"media_type"`
|
|
Limit *int `json:"limit,omitempty"`
|
|
Featured bool `json:"featured"`
|
|
SortOrder int `json:"sort_order,omitempty"`
|
|
PosterURL string `json:"poster_url"`
|
|
PosterSourceURL string `json:"poster_source_url"`
|
|
BackdropSourceURL string `json:"backdrop_source_url"`
|
|
SyncSchedule string `json:"sync_schedule"`
|
|
ManagementMode string `json:"management_mode,omitempty"`
|
|
ManagementSource string `json:"management_source,omitempty"`
|
|
ManagementKey string `json:"management_key,omitempty"`
|
|
PosterFromTemplate bool `json:"-"`
|
|
}
|
|
|
|
// importTMDBFranchiseRequest is the request body for creating a collection
|
|
// backed by a TMDB `/collection/{id}` franchise/saga. Naming distinguishes
|
|
// this from the older importTMDBRequest, which targets TMDB preset listings
|
|
// (popular, trending, etc.) — the two share the on-disk collection_type
|
|
// "tmdb" but differ in source_config.mode (`tmdb_preset` vs `tmdb_collection`).
|
|
type importTMDBFranchiseRequest struct {
|
|
LibraryID int `json:"library_id"`
|
|
LibraryIDs []int `json:"library_ids"`
|
|
Title string `json:"title"`
|
|
Description string `json:"description"`
|
|
CollectionID int `json:"collection_id"`
|
|
Limit *int `json:"limit,omitempty"`
|
|
Featured bool `json:"featured"`
|
|
SortOrder int `json:"sort_order,omitempty"`
|
|
PosterURL string `json:"poster_url"`
|
|
PosterSourceURL string `json:"poster_source_url"`
|
|
BackdropSourceURL string `json:"backdrop_source_url"`
|
|
SyncSchedule string `json:"sync_schedule"`
|
|
ManagementMode string `json:"management_mode,omitempty"`
|
|
ManagementSource string `json:"management_source,omitempty"`
|
|
ManagementKey string `json:"management_key,omitempty"`
|
|
PosterFromTemplate bool `json:"-"`
|
|
}
|
|
|
|
// importTMDBDiscoverRequest is the request body for creating a collection
|
|
// backed by TMDB `/discover/{movie,tv}`. The full discover filter set is
|
|
// stored inline so the same shape can round-trip through source_config.
|
|
//
|
|
// The on-disk collection_type is "tmdb" — the discover specialization lives
|
|
// entirely in source_config.mode = "tmdb_discover".
|
|
type importTMDBDiscoverRequest struct {
|
|
LibraryID int `json:"library_id"`
|
|
LibraryIDs []int `json:"library_ids"`
|
|
Title string `json:"title"`
|
|
Description string `json:"description"`
|
|
MediaType string `json:"media_type"`
|
|
Spec importTMDBDiscoverSpecBody `json:"spec"`
|
|
Limit *int `json:"limit,omitempty"`
|
|
Featured bool `json:"featured"`
|
|
SortOrder int `json:"sort_order,omitempty"`
|
|
PosterURL string `json:"poster_url"`
|
|
PosterSourceURL string `json:"poster_source_url"`
|
|
BackdropSourceURL string `json:"backdrop_source_url"`
|
|
SyncSchedule string `json:"sync_schedule"`
|
|
ManagementMode string `json:"management_mode,omitempty"`
|
|
ManagementSource string `json:"management_source,omitempty"`
|
|
ManagementKey string `json:"management_key,omitempty"`
|
|
PosterFromTemplate bool `json:"-"`
|
|
}
|
|
|
|
// importTMDBDiscoverSpecBody mirrors templates.TMDBDiscoverSpec without the
|
|
// media_type field, which lives on the request envelope.
|
|
type importTMDBDiscoverSpecBody struct {
|
|
WithGenres []int `json:"with_genres,omitempty"`
|
|
WithoutGenres []int `json:"without_genres,omitempty"`
|
|
SortBy string `json:"sort_by"`
|
|
VoteCountGte int `json:"vote_count_gte,omitempty"`
|
|
VoteAverageGte float64 `json:"vote_average_gte,omitempty"`
|
|
ReleaseDateGte string `json:"release_date_gte,omitempty"`
|
|
ReleaseDateLte string `json:"release_date_lte,omitempty"`
|
|
Certifications []string `json:"certifications,omitempty"`
|
|
CertificationLte string `json:"certification_lte,omitempty"`
|
|
WithRuntimeGte int `json:"with_runtime_gte,omitempty"`
|
|
WithRuntimeLte int `json:"with_runtime_lte,omitempty"`
|
|
OriginalLanguage string `json:"original_language,omitempty"`
|
|
}
|
|
|
|
type importTraktRequest struct {
|
|
LibraryID int `json:"library_id"`
|
|
LibraryIDs []int `json:"library_ids"`
|
|
Title string `json:"title"`
|
|
Description string `json:"description"`
|
|
Preset string `json:"preset"`
|
|
MediaType string `json:"media_type"`
|
|
ProfileID string `json:"profile_id,omitempty"`
|
|
Limit *int `json:"limit,omitempty"`
|
|
Featured bool `json:"featured"`
|
|
PosterURL string `json:"poster_url"`
|
|
PosterSourceURL string `json:"poster_source_url"`
|
|
BackdropSourceURL string `json:"backdrop_source_url"`
|
|
SyncSchedule string `json:"sync_schedule"`
|
|
ManagementMode string `json:"management_mode,omitempty"`
|
|
ManagementSource string `json:"management_source,omitempty"`
|
|
ManagementKey string `json:"management_key,omitempty"`
|
|
}
|
|
|
|
type importCollectionResponse struct {
|
|
Collection libraryCollectionResponse `json:"collection"`
|
|
SyncRun *models.LibraryCollectionSyncRun `json:"sync_run,omitempty"`
|
|
}
|
|
|
|
type applyTemplateBundleRequest struct {
|
|
LibraryIDs []int `json:"library_ids"`
|
|
DryRun bool `json:"dry_run"`
|
|
DeleteExisting bool `json:"delete_existing"`
|
|
Featured *templateBundleFeaturedRequest `json:"featured,omitempty"`
|
|
}
|
|
|
|
type templateBundleFeaturedRequest struct {
|
|
Home *templateBundleFeaturedHome `json:"home,omitempty"`
|
|
Libraries map[int]string `json:"libraries,omitempty"`
|
|
}
|
|
|
|
type templateBundleFeaturedHome struct {
|
|
LibraryID int `json:"library_id"`
|
|
TemplateID string `json:"template_id"`
|
|
}
|
|
|
|
type templateBundleApplyEntry struct {
|
|
TemplateID string `json:"template_id"`
|
|
TemplateTitle string `json:"template_title"`
|
|
LibraryID int `json:"library_id"`
|
|
LibraryName string `json:"library_name"`
|
|
CollectionID string `json:"collection_id,omitempty"`
|
|
Reason string `json:"reason,omitempty"`
|
|
}
|
|
|
|
type templateBundleCollectionEntry struct {
|
|
LibraryID int `json:"library_id"`
|
|
LibraryName string `json:"library_name"`
|
|
CollectionID string `json:"collection_id"`
|
|
CollectionTitle string `json:"collection_title"`
|
|
Reason string `json:"reason,omitempty"`
|
|
}
|
|
|
|
type templateBundleFeaturedEntry struct {
|
|
Surface string `json:"surface"`
|
|
LibraryID int `json:"library_id,omitempty"`
|
|
LibraryName string `json:"library_name,omitempty"`
|
|
TemplateID string `json:"template_id"`
|
|
TemplateTitle string `json:"template_title"`
|
|
CollectionID string `json:"collection_id,omitempty"`
|
|
SectionID string `json:"section_id,omitempty"`
|
|
Reason string `json:"reason,omitempty"`
|
|
}
|
|
|
|
type applyTemplateBundleResponse struct {
|
|
BundleID string `json:"bundle_id"`
|
|
DryRun bool `json:"dry_run"`
|
|
DeleteExisting bool `json:"delete_existing"`
|
|
Deleted []templateBundleCollectionEntry `json:"deleted"`
|
|
DeleteSkipped []templateBundleCollectionEntry `json:"delete_skipped"`
|
|
DeleteFailed []templateBundleCollectionEntry `json:"delete_failed"`
|
|
Created []templateBundleApplyEntry `json:"created"`
|
|
Skipped []templateBundleApplyEntry `json:"skipped"`
|
|
Failed []templateBundleApplyEntry `json:"failed"`
|
|
SyncQueued []templateBundleApplyEntry `json:"sync_queued"`
|
|
Featured []templateBundleFeaturedEntry `json:"featured"`
|
|
FeaturedFailed []templateBundleFeaturedEntry `json:"featured_failed"`
|
|
}
|
|
|
|
type pendingTemplateBundleSync struct {
|
|
CollectionID string
|
|
SyncSchedule *string
|
|
Entry templateBundleApplyEntry
|
|
}
|
|
|
|
type templateBundleCollectionRef struct {
|
|
CollectionID string
|
|
Template templates.Template
|
|
Library *models.MediaFolder
|
|
}
|
|
|
|
type templateBundleCollectionRefKey struct {
|
|
LibraryID int
|
|
TemplateID string
|
|
}
|
|
|
|
type templateBundleExistingCollectionKey struct {
|
|
LibraryID int
|
|
Slug string
|
|
}
|
|
|
|
type requestValidationError struct {
|
|
err error
|
|
}
|
|
|
|
func (e requestValidationError) Error() string { return e.err.Error() }
|
|
func (e requestValidationError) Unwrap() error { return e.err }
|
|
|
|
func hasLibrarySelection(libraryID int, libraryIDs []int) bool {
|
|
return libraryID > 0 || len(libraryIDs) > 0
|
|
}
|
|
|
|
func uniquePositiveInts(values []int) []int {
|
|
seen := make(map[int]struct{}, len(values))
|
|
out := make([]int, 0, len(values))
|
|
for _, value := range values {
|
|
if value <= 0 {
|
|
continue
|
|
}
|
|
if _, ok := seen[value]; ok {
|
|
continue
|
|
}
|
|
seen[value] = struct{}{}
|
|
out = append(out, value)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func templateBundleManagementKey(bundleID, templateID string, libraryID int) string {
|
|
return fmt.Sprintf("%s:%s:library:%d", bundleID, templateID, libraryID)
|
|
}
|
|
|
|
func rememberTemplateBundleExistingCollection(
|
|
collections map[templateBundleExistingCollectionKey]*models.LibraryCollection,
|
|
libraryID int,
|
|
collection *models.LibraryCollection,
|
|
) {
|
|
if collection == nil || strings.TrimSpace(collection.Slug) == "" {
|
|
return
|
|
}
|
|
collections[templateBundleExistingCollectionKey{
|
|
LibraryID: libraryID,
|
|
Slug: collection.Slug,
|
|
}] = collection
|
|
}
|
|
|
|
func forgetTemplateBundleExistingCollection(
|
|
collections map[templateBundleExistingCollectionKey]*models.LibraryCollection,
|
|
selectedLibraryIDs map[int]struct{},
|
|
collection *models.LibraryCollection,
|
|
) {
|
|
if collection == nil || strings.TrimSpace(collection.Slug) == "" {
|
|
return
|
|
}
|
|
for _, libraryID := range collection.LibraryIDs {
|
|
if _, ok := selectedLibraryIDs[libraryID]; ok {
|
|
delete(collections, templateBundleExistingCollectionKey{
|
|
LibraryID: libraryID,
|
|
Slug: collection.Slug,
|
|
})
|
|
}
|
|
}
|
|
if len(collection.LibraryIDs) == 0 {
|
|
delete(collections, templateBundleExistingCollectionKey{
|
|
LibraryID: collection.LibraryID,
|
|
Slug: collection.Slug,
|
|
})
|
|
}
|
|
}
|
|
|
|
func templateLimitPtr(tmpl templates.Template) *int {
|
|
if tmpl.DefaultLimit <= 0 {
|
|
return nil
|
|
}
|
|
limit := tmpl.DefaultLimit
|
|
return &limit
|
|
}
|
|
|
|
func templateBundleEntry(
|
|
tmpl templates.Template,
|
|
templateID string,
|
|
library *models.MediaFolder,
|
|
collectionID string,
|
|
reason string,
|
|
) templateBundleApplyEntry {
|
|
title := tmpl.Title
|
|
if title == "" {
|
|
title = templateID
|
|
}
|
|
return templateBundleApplyEntry{
|
|
TemplateID: templateID,
|
|
TemplateTitle: title,
|
|
LibraryID: library.ID,
|
|
LibraryName: library.Name,
|
|
CollectionID: collectionID,
|
|
Reason: reason,
|
|
}
|
|
}
|
|
|
|
func templateBundleCollectionEntryForLibrary(
|
|
collection *models.LibraryCollection,
|
|
library *models.MediaFolder,
|
|
reason string,
|
|
) templateBundleCollectionEntry {
|
|
return templateBundleCollectionEntry{
|
|
LibraryID: library.ID,
|
|
LibraryName: library.Name,
|
|
CollectionID: collection.ID,
|
|
CollectionTitle: collection.Title,
|
|
Reason: reason,
|
|
}
|
|
}
|
|
|
|
func allCollectionLibrariesSelected(collection *models.LibraryCollection, selected map[int]struct{}) bool {
|
|
for _, libraryID := range collection.LibraryIDs {
|
|
if _, ok := selected[libraryID]; !ok {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func templateEligibleForLibrary(tmpl templates.Template, library *models.MediaFolder) bool {
|
|
if library == nil {
|
|
return false
|
|
}
|
|
switch strings.TrimSpace(strings.ToLower(library.Type)) {
|
|
case "", "mixed":
|
|
return true
|
|
case "movie", "movies":
|
|
return tmpl.MediaKind == templates.MediaMovie || tmpl.MediaKind == templates.MediaMixed
|
|
case "series", "tv", "show", "shows", "tvshows":
|
|
return tmpl.MediaKind == templates.MediaTV || tmpl.MediaKind == templates.MediaMixed
|
|
default:
|
|
return true
|
|
}
|
|
}
|
|
|
|
type previewLibraryCollectionResponse struct {
|
|
Items []itemListResponse `json:"items"`
|
|
Total int `json:"total"`
|
|
}
|
|
|
|
type serverUserCollectionsListResponse struct {
|
|
Collections []usercollections.ServerVisibleCollection `json:"collections"`
|
|
}
|
|
|
|
type libraryTabCollection struct {
|
|
ID string `json:"id"`
|
|
Title string `json:"title"`
|
|
PosterURL string `json:"poster_url"`
|
|
PosterThumbhash string `json:"poster_thumbhash,omitempty"`
|
|
ItemCount int `json:"item_count"`
|
|
Featured bool `json:"featured,omitempty"`
|
|
CreatorProfileID *string `json:"creator_profile_id,omitempty"`
|
|
}
|
|
|
|
type libraryTabGroup struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Kind models.LibraryCollectionGroupKind `json:"kind"`
|
|
SortMode models.GroupSortMode `json:"sort_mode"`
|
|
SortOrder int `json:"sort_order"`
|
|
Collections []libraryTabCollection `json:"collections"`
|
|
}
|
|
|
|
type libraryTabUngrouped struct {
|
|
SortOrder int `json:"sort_order"`
|
|
Collections []libraryTabCollection `json:"collections"`
|
|
}
|
|
|
|
type libraryTabResponse struct {
|
|
LibraryID int `json:"library_id"`
|
|
Collections []libraryCollectionResponse `json:"collections,omitempty"`
|
|
Groups []libraryTabGroup `json:"groups"`
|
|
Ungrouped *libraryTabUngrouped `json:"ungrouped,omitempty"`
|
|
}
|
|
|
|
// HandleListLibraryUserCollections returns the viewer's own personal
|
|
// collections that they've opted into their library Collections tab and whose
|
|
// library scope matches the requested library. Personal collections are
|
|
// private to their owner; this endpoint never reveals other users' rows.
|
|
func (h *LibraryCollectionHandler) HandleListLibraryUserCollections(w http.ResponseWriter, r *http.Request) {
|
|
libraryID, ok := parsePathLibraryID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if !requestCanAccessLibrary(r, libraryID) {
|
|
writeError(w, http.StatusNotFound, "not_found", "Library not found")
|
|
return
|
|
}
|
|
if h.UserCollectionPool == nil {
|
|
writeJSON(w, http.StatusOK, serverUserCollectionsListResponse{Collections: []usercollections.ServerVisibleCollection{}})
|
|
return
|
|
}
|
|
|
|
userID := apimw.GetUserID(r.Context())
|
|
collections, err := usercollections.ListServerVisibleByLibrary(r.Context(), h.UserCollectionPool, userID, libraryID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load user collections")
|
|
return
|
|
}
|
|
|
|
if collections == nil {
|
|
collections = []usercollections.ServerVisibleCollection{}
|
|
}
|
|
for i := range collections {
|
|
collections[i].PosterURL = h.presignGPURL(r, collections[i].PosterPath)
|
|
}
|
|
writeJSON(w, http.StatusOK, serverUserCollectionsListResponse{Collections: collections})
|
|
}
|
|
|
|
func (h *LibraryCollectionHandler) HandleListLibraryCollections(w http.ResponseWriter, r *http.Request) {
|
|
libraryID, ok := parsePathLibraryID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if !requestCanAccessLibrary(r, libraryID) {
|
|
writeError(w, http.StatusNotFound, "not_found", "Library not found")
|
|
return
|
|
}
|
|
|
|
adminCollections, err := h.repo.ListByLibrary(r.Context(), libraryID, catalog.ListLibraryCollectionsOptions{})
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load collections")
|
|
return
|
|
}
|
|
if h.GroupRepo == nil {
|
|
writeJSON(w, http.StatusOK, libraryCollectionsListResponse{
|
|
Collections: h.toLibraryCollectionResponses(r, adminCollections),
|
|
})
|
|
return
|
|
}
|
|
|
|
userID := apimw.GetUserID(r.Context())
|
|
groups, err := h.GroupRepo.ListByLibrary(r.Context(), libraryID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load groups")
|
|
return
|
|
}
|
|
adminCollectionsByGroup := groupLibraryTabCollections(adminCollections)
|
|
|
|
resp := libraryTabResponse{
|
|
LibraryID: libraryID,
|
|
Collections: h.toLibraryCollectionResponses(r, adminCollections),
|
|
Groups: []libraryTabGroup{},
|
|
}
|
|
var userCollections []usercollections.ServerVisibleCollection
|
|
userCollectionsLoaded := false
|
|
for _, g := range groups {
|
|
var colls []libraryTabCollection
|
|
switch g.Kind {
|
|
case models.GroupKindUserCollections:
|
|
if h.UserCollectionPool == nil || userID == 0 {
|
|
continue
|
|
}
|
|
if !userCollectionsLoaded {
|
|
loadedUserCollections, loadErr := usercollections.ListServerVisibleByLibrary(r.Context(), h.UserCollectionPool, userID, libraryID)
|
|
if loadErr != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load user collections")
|
|
return
|
|
}
|
|
userCollections = loadedUserCollections
|
|
userCollectionsLoaded = true
|
|
}
|
|
sorted := applyUserCollectionSort(userCollections, g.DefaultSortMode)
|
|
for i := range sorted {
|
|
posterURL := h.presignGPURL(r, sorted[i].PosterPath)
|
|
creatorProfileID := sorted[i].CreatorProfileID
|
|
colls = append(colls, libraryTabCollection{
|
|
ID: sorted[i].ID,
|
|
Title: sorted[i].Name,
|
|
PosterURL: posterURL,
|
|
PosterThumbhash: sorted[i].PosterThumbhash,
|
|
ItemCount: sorted[i].ItemCount,
|
|
CreatorProfileID: &creatorProfileID,
|
|
})
|
|
}
|
|
default:
|
|
collections := adminCollectionsByGroup[g.ID]
|
|
collections = applyCollectionSort(collections, g.DefaultSortMode)
|
|
for _, c := range collections {
|
|
colls = append(colls, libraryTabCollection{
|
|
ID: c.ID,
|
|
Title: c.Title,
|
|
PosterURL: h.presignGPURL(r, c.PosterURL),
|
|
PosterThumbhash: c.PosterThumbhash,
|
|
ItemCount: c.ItemCount,
|
|
Featured: c.Featured,
|
|
})
|
|
}
|
|
}
|
|
if len(colls) == 0 {
|
|
continue
|
|
}
|
|
resp.Groups = append(resp.Groups, libraryTabGroup{
|
|
ID: g.ID,
|
|
Name: g.Name,
|
|
Kind: g.Kind,
|
|
SortMode: g.DefaultSortMode,
|
|
SortOrder: g.SortOrder,
|
|
Collections: colls,
|
|
})
|
|
}
|
|
|
|
ungrouped := adminCollectionsByGroup[groupKeyPtr(nil)]
|
|
if len(ungrouped) > 0 {
|
|
uColls := make([]libraryTabCollection, 0, len(ungrouped))
|
|
for _, c := range ungrouped {
|
|
uColls = append(uColls, libraryTabCollection{
|
|
ID: c.ID,
|
|
Title: c.Title,
|
|
PosterURL: h.presignGPURL(r, c.PosterURL),
|
|
PosterThumbhash: c.PosterThumbhash,
|
|
ItemCount: c.ItemCount,
|
|
Featured: c.Featured,
|
|
})
|
|
}
|
|
sortOrder := 9999
|
|
if h.GroupRepo != nil {
|
|
if sortOrder, err = h.GroupRepo.GetUngroupedSortOrder(r.Context(), libraryID); err != nil {
|
|
sortOrder = 9999
|
|
}
|
|
}
|
|
resp.Ungrouped = &libraryTabUngrouped{SortOrder: sortOrder, Collections: uColls}
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
func toLibraryCollectionGroupResponses(groups []models.LibraryCollectionGroup) []libraryCollectionGroupResponse {
|
|
out := make([]libraryCollectionGroupResponse, 0, len(groups))
|
|
for _, g := range groups {
|
|
out = append(out, toLibraryCollectionGroupResponse(g))
|
|
}
|
|
return out
|
|
}
|
|
|
|
func groupLibraryTabCollections(collections []*models.LibraryCollection) map[string][]*models.LibraryCollection {
|
|
grouped := make(map[string][]*models.LibraryCollection)
|
|
for _, collection := range collections {
|
|
key := groupKeyPtr(collection.GroupID)
|
|
grouped[key] = append(grouped[key], collection)
|
|
}
|
|
return grouped
|
|
}
|
|
|
|
func groupKeyPtr(id *string) string {
|
|
if id == nil {
|
|
return ""
|
|
}
|
|
return *id
|
|
}
|
|
|
|
func toLibraryCollectionGroupResponse(g models.LibraryCollectionGroup) libraryCollectionGroupResponse {
|
|
return libraryCollectionGroupResponse{
|
|
ID: g.ID,
|
|
LibraryID: g.LibraryID,
|
|
Name: g.Name,
|
|
Slug: g.Slug,
|
|
Kind: string(g.Kind),
|
|
DefaultSortMode: string(g.DefaultSortMode),
|
|
SortOrder: g.SortOrder,
|
|
}
|
|
}
|
|
|
|
func applyCollectionSort(collections []*models.LibraryCollection, mode models.GroupSortMode) []*models.LibraryCollection {
|
|
out := append([]*models.LibraryCollection(nil), collections...)
|
|
switch mode {
|
|
case models.GroupSortNameAsc:
|
|
sort.Slice(out, func(i, j int) bool { return out[i].Title < out[j].Title })
|
|
case models.GroupSortNameDesc:
|
|
sort.Slice(out, func(i, j int) bool { return out[i].Title > out[j].Title })
|
|
case models.GroupSortRecent:
|
|
sort.Slice(out, func(i, j int) bool { return out[i].UpdatedAt.After(out[j].UpdatedAt) })
|
|
case models.GroupSortMostItems:
|
|
sort.Slice(out, func(i, j int) bool { return out[i].ItemCount > out[j].ItemCount })
|
|
}
|
|
return out
|
|
}
|
|
|
|
func applyUserCollectionSort(collections []usercollections.ServerVisibleCollection, mode models.GroupSortMode) []usercollections.ServerVisibleCollection {
|
|
out := append([]usercollections.ServerVisibleCollection(nil), collections...)
|
|
switch mode {
|
|
case models.GroupSortNameAsc:
|
|
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
|
case models.GroupSortNameDesc:
|
|
sort.Slice(out, func(i, j int) bool { return out[i].Name > out[j].Name })
|
|
case models.GroupSortRecent:
|
|
sort.Slice(out, func(i, j int) bool { return out[i].UpdatedAt > out[j].UpdatedAt })
|
|
case models.GroupSortMostItems:
|
|
sort.Slice(out, func(i, j int) bool { return out[i].ItemCount > out[j].ItemCount })
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (h *LibraryCollectionHandler) refreshSmartCountAsync(collectionID string) {
|
|
if h.SmartCountRefresher == nil || collectionID == "" {
|
|
return
|
|
}
|
|
go func() {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
|
defer cancel()
|
|
if err := h.SmartCountRefresher.RefreshOne(ctx, collectionID); err != nil {
|
|
slog.Warn("smart-count refresh failed", "collection_id", collectionID, "error", err)
|
|
}
|
|
}()
|
|
}
|
|
|
|
func (h *LibraryCollectionHandler) HandleGetLibraryCollectionItems(w http.ResponseWriter, r *http.Request) {
|
|
libraryID, ok := parsePathLibraryID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if !requestCanAccessLibrary(r, libraryID) {
|
|
writeError(w, http.StatusNotFound, "not_found", "Library not found")
|
|
return
|
|
}
|
|
|
|
collectionID := chi.URLParam(r, "collection_id")
|
|
collection, err := h.repo.GetByID(r.Context(), collectionID)
|
|
if err != nil || collection.LibraryID != libraryID || collection.Visibility != "visible" {
|
|
writeError(w, http.StatusNotFound, "not_found", "Collection not found")
|
|
return
|
|
}
|
|
|
|
var items []itemListResponse
|
|
if catalog.IsLiveQueryType(collection.CollectionType) {
|
|
items, err = h.loadLiveCollectionItems(r, collection)
|
|
} else {
|
|
items, err = h.loadOrderedCollectionItems(r, collectionID)
|
|
}
|
|
if err != nil {
|
|
var queryErr smartCollectionQueryError
|
|
if errors.As(err, &queryErr) {
|
|
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
|
return
|
|
}
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load collection items")
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, browseResponse{Items: items, Total: len(items), HasMore: false})
|
|
}
|
|
|
|
func (h *LibraryCollectionHandler) HandleListAdminCollections(w http.ResponseWriter, r *http.Request) {
|
|
var libraryID *int
|
|
if raw := r.URL.Query().Get("library_id"); raw != "" {
|
|
parsed, err := strconv.Atoi(raw)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid library_id")
|
|
return
|
|
}
|
|
libraryID = &parsed
|
|
}
|
|
|
|
collectionsCh := make(chan []*models.LibraryCollection, 1)
|
|
eg, egCtx := errgroup.WithContext(r.Context())
|
|
eg.Go(func() error {
|
|
collections, err := h.repo.ListAll(egCtx, libraryID, catalog.ListLibraryCollectionsOptions{IncludeHidden: true})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
collectionsCh <- collections
|
|
return nil
|
|
})
|
|
var groupsCh chan []models.LibraryCollectionGroup
|
|
if libraryID != nil && h.GroupRepo != nil {
|
|
scopedID := *libraryID
|
|
groupsCh = make(chan []models.LibraryCollectionGroup, 1)
|
|
eg.Go(func() error {
|
|
groups, err := h.GroupRepo.ListByLibrary(egCtx, scopedID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
groupsCh <- groups
|
|
return nil
|
|
})
|
|
}
|
|
if err := eg.Wait(); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load collections")
|
|
return
|
|
}
|
|
collections := <-collectionsCh
|
|
var groups []models.LibraryCollectionGroup
|
|
if groupsCh != nil {
|
|
groups = <-groupsCh
|
|
}
|
|
|
|
resp := libraryCollectionsListResponse{
|
|
Collections: make([]libraryCollectionResponse, 0, len(collections)),
|
|
}
|
|
for _, collection := range collections {
|
|
resp.Collections = append(resp.Collections, h.toLibraryCollectionResponse(r, collection))
|
|
}
|
|
if libraryID != nil {
|
|
resp.Groups = toLibraryCollectionGroupResponses(groups)
|
|
}
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
func (h *LibraryCollectionHandler) HandleCreateAdminCollection(w http.ResponseWriter, r *http.Request) {
|
|
var req createLibraryCollectionRequest
|
|
if err := decodeJSONOrMultipart(r, &req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
|
return
|
|
}
|
|
if !hasLibrarySelection(req.LibraryID, req.LibraryIDs) || strings.TrimSpace(req.Title) == "" {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "library_id/library_ids and title are required")
|
|
return
|
|
}
|
|
if req.Slug == "" {
|
|
req.Slug = slugifyCollectionName(req.Title)
|
|
}
|
|
if req.CollectionType == "" {
|
|
req.CollectionType = "manual"
|
|
}
|
|
queryDefinition := defaultJSON(req.QueryDefinition)
|
|
if req.CollectionType == "smart" {
|
|
var err error
|
|
queryDefinition, err = normalizeSmartCollectionQueryDefinitionJSON(queryDefinition, false, false)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid query_definition")
|
|
return
|
|
}
|
|
} else if len(req.QueryDefinition) > 0 {
|
|
var err error
|
|
queryDefinition, err = normalizeQueryDefinitionJSON(queryDefinition, false, false)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid query_definition")
|
|
return
|
|
}
|
|
}
|
|
|
|
var syncSchedule *string
|
|
if s := strings.TrimSpace(req.SyncSchedule); s != "" {
|
|
if err := catalog.ParseCronExpression(s); err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
|
return
|
|
}
|
|
syncSchedule = &s
|
|
}
|
|
managementMode, managementSource, managementKey, err := normalizeCollectionManagementFields(req.ManagementMode, req.ManagementSource, req.ManagementKey)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
|
return
|
|
}
|
|
|
|
collection, err := h.repo.Create(r.Context(), catalog.CreateLibraryCollectionInput{
|
|
LibraryID: req.LibraryID,
|
|
LibraryIDs: req.LibraryIDs,
|
|
Slug: req.Slug,
|
|
Title: req.Title,
|
|
Description: req.Description,
|
|
CollectionType: req.CollectionType,
|
|
Visibility: defaultCollectionVisibility(req.Visibility),
|
|
SortOrder: req.SortOrder,
|
|
GroupID: req.GroupID,
|
|
Featured: req.Featured,
|
|
PosterURL: req.PosterURL,
|
|
BackdropURL: req.BackdropURL,
|
|
SourceURL: req.SourceURL,
|
|
QueryDefinition: queryDefinition,
|
|
SortConfig: defaultJSON(req.SortConfig),
|
|
SourceConfig: defaultCollectionSourceConfig(req.SourceConfig),
|
|
ManagementMode: managementMode,
|
|
ManagementSource: managementSource,
|
|
ManagementKey: managementKey,
|
|
SyncSchedule: syncSchedule,
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to create collection")
|
|
return
|
|
}
|
|
|
|
posterStored, _, _, err := h.storeBundledTemplatePoster(r.Context(), collection.ID, req.PosterURL, false)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to process template poster")
|
|
return
|
|
}
|
|
if err := h.processArtworkInputs(r, collection.ID, req.PosterSourceURL, req.BackdropSourceURL); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to process uploaded images")
|
|
return
|
|
}
|
|
if posterStored || r.MultipartForm != nil || strings.TrimSpace(req.PosterSourceURL) != "" || strings.TrimSpace(req.BackdropSourceURL) != "" {
|
|
collection, err = h.repo.GetByID(r.Context(), collection.ID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load collection")
|
|
return
|
|
}
|
|
}
|
|
h.refreshSmartCountAsync(collection.ID)
|
|
|
|
writeJSON(w, http.StatusCreated, h.toLibraryCollectionResponse(r, collection))
|
|
}
|
|
|
|
func (h *LibraryCollectionHandler) HandleUpdateAdminCollection(w http.ResponseWriter, r *http.Request) {
|
|
collectionID := chi.URLParam(r, "id")
|
|
if collectionID == "" {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Collection ID is required")
|
|
return
|
|
}
|
|
|
|
var req updateLibraryCollectionRequest
|
|
if err := decodeJSONOrMultipart(r, &req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
|
return
|
|
}
|
|
queryDefinition := req.QueryDefinition
|
|
if len(req.QueryDefinition) > 0 {
|
|
var err error
|
|
if req.CollectionType == nil || *req.CollectionType == "smart" {
|
|
queryDefinition, err = normalizeSmartCollectionQueryDefinitionJSON(req.QueryDefinition, false, false)
|
|
} else {
|
|
queryDefinition, err = normalizeQueryDefinitionJSON(req.QueryDefinition, false, false)
|
|
}
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid query_definition")
|
|
return
|
|
}
|
|
}
|
|
|
|
// Validate sync_schedule if provided.
|
|
if req.SyncSchedule != nil && *req.SyncSchedule != "" {
|
|
if err := catalog.ParseCronExpression(*req.SyncSchedule); err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
|
return
|
|
}
|
|
}
|
|
|
|
var setGroupID **string
|
|
if req.GroupID.Set() {
|
|
groupID := req.GroupID.Value()
|
|
if groupID != nil && strings.TrimSpace(*groupID) == "" {
|
|
groupID = nil
|
|
}
|
|
setGroupID = &groupID
|
|
}
|
|
|
|
if err := h.repo.Update(r.Context(), catalog.UpdateLibraryCollectionInput{
|
|
ID: collectionID,
|
|
LibraryIDs: req.LibraryIDs,
|
|
Slug: req.Slug,
|
|
Title: req.Title,
|
|
Description: req.Description,
|
|
CollectionType: req.CollectionType,
|
|
Visibility: req.Visibility,
|
|
SortOrder: req.SortOrder,
|
|
SetGroupID: setGroupID,
|
|
Featured: req.Featured,
|
|
PosterURL: req.PosterURL,
|
|
BackdropURL: req.BackdropURL,
|
|
SourceURL: req.SourceURL,
|
|
QueryDefinition: queryDefinition,
|
|
SortConfig: req.SortConfig,
|
|
SourceConfig: req.SourceConfig,
|
|
ManagementMode: normalizeOptionalCollectionManagementMode(req.ManagementMode),
|
|
ManagementSource: req.ManagementSource,
|
|
ManagementKey: req.ManagementKey,
|
|
SyncSchedule: req.SyncSchedule,
|
|
}); err != nil {
|
|
if err == catalog.ErrLibraryCollectionNotFound {
|
|
writeError(w, http.StatusNotFound, "not_found", "Collection not found")
|
|
return
|
|
}
|
|
if errors.Is(err, catalog.ErrLibraryCollectionGroupNotFound) {
|
|
writeError(w, http.StatusNotFound, "not_found", "Collection group not found")
|
|
return
|
|
}
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update collection")
|
|
return
|
|
}
|
|
|
|
if err := h.processArtworkInputs(r, collectionID, pointerStringValue(req.PosterSourceURL), pointerStringValue(req.BackdropSourceURL)); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to process uploaded images")
|
|
return
|
|
}
|
|
|
|
updated, err := h.repo.GetByID(r.Context(), collectionID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load collection")
|
|
return
|
|
}
|
|
if len(queryDefinition) > 0 || req.CollectionType != nil {
|
|
h.refreshSmartCountAsync(collectionID)
|
|
}
|
|
writeJSON(w, http.StatusOK, h.toLibraryCollectionResponse(r, updated))
|
|
}
|
|
|
|
func (h *LibraryCollectionHandler) HandlePreviewAdminCollection(w http.ResponseWriter, r *http.Request) {
|
|
if h.Executor == nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Preview is not configured")
|
|
return
|
|
}
|
|
|
|
var req previewCollectionRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
|
return
|
|
}
|
|
|
|
var def catalog.QueryDefinition
|
|
if len(req.QueryDefinition) > 0 {
|
|
normalized, err := normalizeQueryDefinitionJSON(req.QueryDefinition, false, false)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid query_definition")
|
|
return
|
|
}
|
|
if err := json.Unmarshal(normalized, &def); err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid query_definition")
|
|
return
|
|
}
|
|
}
|
|
|
|
items, total, err := h.Executor.Preview(r.Context(), def, catalog.AccessFilter{}, req.Limit)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
|
return
|
|
}
|
|
|
|
resp := previewLibraryCollectionResponse{Items: make([]itemListResponse, 0, len(items)), Total: total}
|
|
for _, item := range items {
|
|
resp.Items = append(resp.Items, h.toItemListResponse(r, item))
|
|
}
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
func (h *LibraryCollectionHandler) HandleDeleteCollectionImage(w http.ResponseWriter, r *http.Request) {
|
|
collectionID := chi.URLParam(r, "id")
|
|
if collectionID == "" {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Collection ID is required")
|
|
return
|
|
}
|
|
|
|
imageType := r.URL.Query().Get("type")
|
|
switch imageType {
|
|
case "poster", "backdrop":
|
|
default:
|
|
writeError(w, http.StatusBadRequest, "bad_request", "type must be \"poster\" or \"backdrop\"")
|
|
return
|
|
}
|
|
|
|
if err := h.deleteCollectionImages(r.Context(), collectionID, imageType); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete images")
|
|
return
|
|
}
|
|
|
|
empty := ""
|
|
notAutoGen := false
|
|
notFromTemplate := false
|
|
input := catalog.UpdateLibraryCollectionInput{ID: collectionID}
|
|
if imageType == "poster" {
|
|
input.PosterURL = &empty
|
|
input.PosterThumbhash = &empty
|
|
input.PosterAutoGenerated = ¬AutoGen
|
|
input.PosterFromTemplate = ¬FromTemplate
|
|
} else {
|
|
input.BackdropURL = &empty
|
|
input.BackdropThumbhash = &empty
|
|
}
|
|
if err := h.repo.Update(r.Context(), input); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update collection")
|
|
return
|
|
}
|
|
|
|
// After clearing, attempt to auto-generate a poster collage.
|
|
if imageType == "poster" && h.service.CollageGen != nil {
|
|
if err := h.GenerateCollectionPoster(r.Context(), collectionID); err != nil {
|
|
if errors.Is(err, collage.ErrNotEnoughImages) {
|
|
slog.Debug("collage: not enough images to regenerate after delete", "collection_id", collectionID)
|
|
} else {
|
|
slog.Warn("collage: failed to regenerate poster after delete", "collection_id", collectionID, "error", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (h *LibraryCollectionHandler) HandleDeleteAdminCollection(w http.ResponseWriter, r *http.Request) {
|
|
collectionID := chi.URLParam(r, "id")
|
|
if collectionID == "" {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Collection ID is required")
|
|
return
|
|
}
|
|
if err := h.deleteServerCollection(r.Context(), collectionID); err != nil {
|
|
switch {
|
|
case errors.Is(err, errLibraryCollectionInUse):
|
|
writeError(w, http.StatusConflict, "collection_in_use", "Collection is used by one or more sections")
|
|
case errors.Is(err, catalog.ErrLibraryCollectionNotFound):
|
|
writeError(w, http.StatusNotFound, "not_found", "Collection not found")
|
|
default:
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete collection")
|
|
}
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (h *LibraryCollectionHandler) deleteServerCollection(ctx context.Context, collectionID string) error {
|
|
if h.SectionRepo != nil {
|
|
refs, err := h.SectionRepo.CountLibraryCollectionReferences(ctx, collectionID, "")
|
|
if err != nil {
|
|
return fmt.Errorf("checking collection usage: %w", err)
|
|
}
|
|
if refs > 0 {
|
|
return errLibraryCollectionInUse
|
|
}
|
|
}
|
|
|
|
// Clean up S3 images before deleting the collection row. Failures here
|
|
// only leak storage; the collection row delete must still proceed so the
|
|
// admin's request succeeds.
|
|
if h.s3GP != nil {
|
|
prefix := fmt.Sprintf("collection-images/%s/", collectionID)
|
|
keys, err := h.s3GP.ListObjects(ctx, h.s3GP.Bucket(), prefix)
|
|
if err != nil {
|
|
slog.Warn("collection delete: listing S3 images failed; image keys may leak",
|
|
"collection_id", collectionID, "prefix", prefix, "error", err)
|
|
}
|
|
for _, key := range keys {
|
|
if err := h.s3GP.DeleteObject(ctx, h.s3GP.Bucket(), key); err != nil {
|
|
slog.Warn("collection delete: removing S3 image failed; key leaks",
|
|
"collection_id", collectionID, "key", key, "error", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
return h.repo.Delete(ctx, collectionID)
|
|
}
|
|
|
|
type adminReorderCollectionsRequest struct {
|
|
LibraryID int `json:"library_id"`
|
|
OrderedIDs []string `json:"ordered_ids"`
|
|
GroupID *string `json:"group_id,omitempty"`
|
|
}
|
|
|
|
type adminReorderItemsRequest struct {
|
|
OrderedIDs []string `json:"ordered_ids"`
|
|
}
|
|
|
|
type adminCollectionItemRequest struct {
|
|
Position int `json:"position"`
|
|
}
|
|
|
|
// HandleReorderAdminCollections handles PUT /admin/collections/order.
|
|
// Accepts a library_id and the full list of collection ids in the new order.
|
|
func (h *LibraryCollectionHandler) HandleReorderAdminCollections(w http.ResponseWriter, r *http.Request) {
|
|
var req adminReorderCollectionsRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
|
return
|
|
}
|
|
if req.LibraryID <= 0 {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "library_id is required")
|
|
return
|
|
}
|
|
if err := h.repo.ReorderCollections(r.Context(), req.LibraryID, req.GroupID, req.OrderedIDs); err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// requireManualCollection loads a collection by URL param "id" and rejects
|
|
// the request unless it is a manual collection. Returns nil after writing
|
|
// the appropriate HTTP error response if the collection is missing, of the
|
|
// wrong type, or the database lookup failed.
|
|
func (h *LibraryCollectionHandler) requireManualCollection(w http.ResponseWriter, r *http.Request) (string, bool) {
|
|
collectionID := chi.URLParam(r, "id")
|
|
if collectionID == "" {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Collection ID is required")
|
|
return "", false
|
|
}
|
|
collection, err := h.repo.GetByID(r.Context(), collectionID)
|
|
if err != nil {
|
|
if errors.Is(err, catalog.ErrLibraryCollectionNotFound) {
|
|
writeError(w, http.StatusNotFound, "not_found", "Collection not found")
|
|
return "", false
|
|
}
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load collection")
|
|
return "", false
|
|
}
|
|
if collection.CollectionType != "manual" {
|
|
writeError(w, http.StatusConflict, "collection_not_manual", "Only manual collections support manual items")
|
|
return "", false
|
|
}
|
|
return collectionID, true
|
|
}
|
|
|
|
// HandleReorderAdminCollectionItems handles PUT /admin/collections/{id}/items/order.
|
|
// Manual collections only — synced types (mdblist/tmdb/trakt) and smart
|
|
// collections reject the call.
|
|
func (h *LibraryCollectionHandler) HandleReorderAdminCollectionItems(w http.ResponseWriter, r *http.Request) {
|
|
collectionID, ok := h.requireManualCollection(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
var req adminReorderItemsRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
|
return
|
|
}
|
|
if err := h.repo.ReorderItems(r.Context(), collectionID, req.OrderedIDs); err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// HandleAddAdminCollectionItem handles PUT /admin/collections/{id}/items/{item_id}.
|
|
// Adds an item or updates its position. Manual collections only.
|
|
func (h *LibraryCollectionHandler) HandleAddAdminCollectionItem(w http.ResponseWriter, r *http.Request) {
|
|
collectionID, ok := h.requireManualCollection(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
itemID := chi.URLParam(r, "item_id")
|
|
if itemID == "" {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "item ID is required")
|
|
return
|
|
}
|
|
|
|
var req adminCollectionItemRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
if !errors.Is(err, io.EOF) {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
|
return
|
|
}
|
|
// Empty body is allowed; defaults to position 0.
|
|
}
|
|
if err := h.repo.AddItem(r.Context(), collectionID, itemID, req.Position); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to add collection item")
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// HandleRemoveAdminCollectionItem handles DELETE /admin/collections/{id}/items/{item_id}.
|
|
// Manual collections only.
|
|
func (h *LibraryCollectionHandler) HandleRemoveAdminCollectionItem(w http.ResponseWriter, r *http.Request) {
|
|
collectionID, ok := h.requireManualCollection(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
itemID := chi.URLParam(r, "item_id")
|
|
if itemID == "" {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "item ID is required")
|
|
return
|
|
}
|
|
if err := h.repo.RemoveItem(r.Context(), collectionID, itemID); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to remove collection item")
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (h *LibraryCollectionHandler) HandleSyncAdminCollection(w http.ResponseWriter, r *http.Request) {
|
|
collectionID := chi.URLParam(r, "id")
|
|
run, err := h.service.SyncCollection(r.Context(), collectionID)
|
|
if err != nil {
|
|
if errors.Is(err, catalog.ErrLibraryCollectionSyncUnsupported) {
|
|
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
|
return
|
|
}
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to sync collection")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, run)
|
|
}
|
|
|
|
func (h *LibraryCollectionHandler) templateRegistry() *templates.Registry {
|
|
if h.TemplateRegistry != nil {
|
|
return h.TemplateRegistry
|
|
}
|
|
return templates.Default
|
|
}
|
|
|
|
func (h *LibraryCollectionHandler) HandleListTemplateBundles(w http.ResponseWriter, r *http.Request) {
|
|
writeJSON(w, http.StatusOK, h.templateRegistry().BundleCatalog())
|
|
}
|
|
|
|
type templateBundleApplyError struct {
|
|
status int
|
|
code string
|
|
message string
|
|
}
|
|
|
|
func (e templateBundleApplyError) Error() string {
|
|
if e.message != "" {
|
|
return e.message
|
|
}
|
|
return e.code
|
|
}
|
|
|
|
func newTemplateBundleApplyError(status int, code, message string) error {
|
|
return templateBundleApplyError{status: status, code: code, message: message}
|
|
}
|
|
|
|
func (h *LibraryCollectionHandler) HandleApplyTemplateBundle(w http.ResponseWriter, r *http.Request) {
|
|
bundleID := chi.URLParam(r, "bundleID")
|
|
|
|
var req applyTemplateBundleRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
|
return
|
|
}
|
|
|
|
// Keep the legacy synchronous endpoint compatible, including the previous
|
|
// detached context for non-dry-runs. The admin UI uses HandleApplyTemplateBundleJob
|
|
// for real applies so large batches no longer depend on this request.
|
|
workCtx := r.Context()
|
|
if !req.DryRun {
|
|
workCtx = context.WithoutCancel(r.Context())
|
|
}
|
|
resp, err := h.applyTemplateBundle(workCtx, bundleID, req, nil)
|
|
if err != nil {
|
|
writeTemplateBundleApplyError(w, err)
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
func (h *LibraryCollectionHandler) HandleApplyTemplateBundleJob(w http.ResponseWriter, r *http.Request) {
|
|
if h.JobRepo == nil {
|
|
writeError(w, http.StatusServiceUnavailable, "service_unavailable", "Admin job queue is not configured")
|
|
return
|
|
}
|
|
|
|
bundleID := chi.URLParam(r, "bundleID")
|
|
var req applyTemplateBundleRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
|
return
|
|
}
|
|
if req.DryRun {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "dry_run is not supported for background apply jobs")
|
|
return
|
|
}
|
|
if _, ok := h.templateRegistry().GetBundle(bundleID); !ok {
|
|
writeError(w, http.StatusNotFound, "not_found", "Template bundle not found")
|
|
return
|
|
}
|
|
|
|
libraryIDs := uniquePositiveInts(req.LibraryIDs)
|
|
if len(libraryIDs) == 0 {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "library_ids is required")
|
|
return
|
|
}
|
|
|
|
job, err := h.JobRepo.Create(r.Context(), adminjob.CreateJobInput{
|
|
JobType: adminjob.JobTypeTemplateBundleApply,
|
|
CreatedByUserID: currentAdminUserID(r),
|
|
RequestPayload: adminjob.TemplateBundleApplyRequest{
|
|
BundleID: bundleID,
|
|
LibraryIDs: libraryIDs,
|
|
DeleteExisting: req.DeleteExisting,
|
|
Featured: toAdminJobTemplateBundleFeatured(req.Featured),
|
|
},
|
|
Message: "Queued collection defaults apply",
|
|
})
|
|
if err != nil {
|
|
var conflict *adminjob.ActiveJobConflictError
|
|
if errors.As(err, &conflict) {
|
|
writeAdminJobConflict(w, "A collection defaults apply is already queued or running", conflict.Job, NewAdminJobsHandler(h.JobRepo, nil), r)
|
|
return
|
|
}
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to queue collection defaults apply")
|
|
return
|
|
}
|
|
|
|
publishEventJob(r.Context(), h.EventsHub, "job.created", job)
|
|
writeJSON(w, http.StatusAccepted, adminJobToResponse(r, job, nil))
|
|
}
|
|
|
|
func writeTemplateBundleApplyError(w http.ResponseWriter, err error) {
|
|
var applyErr templateBundleApplyError
|
|
if errors.As(err, &applyErr) {
|
|
writeError(w, applyErr.status, applyErr.code, applyErr.message)
|
|
return
|
|
}
|
|
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
|
|
}
|
|
|
|
func toAdminJobTemplateBundleFeatured(req *templateBundleFeaturedRequest) *adminjob.TemplateBundleApplyFeaturedRequest {
|
|
if req == nil {
|
|
return nil
|
|
}
|
|
var home *adminjob.TemplateBundleApplyFeaturedHome
|
|
if req.Home != nil {
|
|
home = &adminjob.TemplateBundleApplyFeaturedHome{
|
|
LibraryID: req.Home.LibraryID,
|
|
TemplateID: req.Home.TemplateID,
|
|
}
|
|
}
|
|
libraries := make(map[int]string, len(req.Libraries))
|
|
for libraryID, templateID := range req.Libraries {
|
|
libraries[libraryID] = templateID
|
|
}
|
|
return &adminjob.TemplateBundleApplyFeaturedRequest{
|
|
Home: home,
|
|
Libraries: libraries,
|
|
}
|
|
}
|
|
|
|
func fromAdminJobTemplateBundleFeatured(req *adminjob.TemplateBundleApplyFeaturedRequest) *templateBundleFeaturedRequest {
|
|
if req == nil {
|
|
return nil
|
|
}
|
|
var home *templateBundleFeaturedHome
|
|
if req.Home != nil {
|
|
home = &templateBundleFeaturedHome{
|
|
LibraryID: req.Home.LibraryID,
|
|
TemplateID: req.Home.TemplateID,
|
|
}
|
|
}
|
|
libraries := make(map[int]string, len(req.Libraries))
|
|
for libraryID, templateID := range req.Libraries {
|
|
libraries[libraryID] = templateID
|
|
}
|
|
return &templateBundleFeaturedRequest{
|
|
Home: home,
|
|
Libraries: libraries,
|
|
}
|
|
}
|
|
|
|
func (h *LibraryCollectionHandler) ExecuteTemplateBundleApply(
|
|
ctx context.Context,
|
|
req adminjob.TemplateBundleApplyRequest,
|
|
progress func(current, total int, message string),
|
|
) (any, error) {
|
|
resp, err := h.applyTemplateBundle(ctx, req.BundleID, applyTemplateBundleRequest{
|
|
LibraryIDs: req.LibraryIDs,
|
|
DeleteExisting: req.DeleteExisting,
|
|
Featured: fromAdminJobTemplateBundleFeatured(req.Featured),
|
|
}, progress)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return resp, nil
|
|
}
|
|
|
|
func (h *LibraryCollectionHandler) applyTemplateBundle(
|
|
ctx context.Context,
|
|
bundleID string,
|
|
req applyTemplateBundleRequest,
|
|
progress func(current, total int, message string),
|
|
) (applyTemplateBundleResponse, error) {
|
|
reportTemplateBundleApplyProgress(progress, "Loading selected libraries")
|
|
bundle, ok := h.templateRegistry().GetBundle(bundleID)
|
|
if !ok {
|
|
return applyTemplateBundleResponse{}, newTemplateBundleApplyError(http.StatusNotFound, "not_found", "Template bundle not found")
|
|
}
|
|
|
|
libraryIDs := uniquePositiveInts(req.LibraryIDs)
|
|
if len(libraryIDs) == 0 {
|
|
return applyTemplateBundleResponse{}, newTemplateBundleApplyError(http.StatusBadRequest, "bad_request", "library_ids is required")
|
|
}
|
|
if h.FolderRepo == nil {
|
|
return applyTemplateBundleResponse{}, newTemplateBundleApplyError(http.StatusInternalServerError, "internal_error", "Library lookup is not configured")
|
|
}
|
|
if h.repo == nil || (!req.DryRun && h.service == nil) {
|
|
return applyTemplateBundleResponse{}, newTemplateBundleApplyError(http.StatusInternalServerError, "internal_error", "Collection service is not configured")
|
|
}
|
|
|
|
libraries, err := h.FolderRepo.ListByIDs(ctx, libraryIDs)
|
|
if err != nil {
|
|
return applyTemplateBundleResponse{}, newTemplateBundleApplyError(http.StatusInternalServerError, "internal_error", "Failed to load libraries")
|
|
}
|
|
librariesByID := make(map[int]*models.MediaFolder, len(libraries))
|
|
for _, library := range libraries {
|
|
librariesByID[library.ID] = library
|
|
}
|
|
if len(librariesByID) != len(libraryIDs) {
|
|
return applyTemplateBundleResponse{}, newTemplateBundleApplyError(http.StatusBadRequest, "bad_request", "library_ids must reference enabled libraries")
|
|
}
|
|
|
|
resp := applyTemplateBundleResponse{
|
|
BundleID: bundle.ID,
|
|
DryRun: req.DryRun,
|
|
DeleteExisting: req.DeleteExisting,
|
|
Deleted: []templateBundleCollectionEntry{},
|
|
DeleteSkipped: []templateBundleCollectionEntry{},
|
|
DeleteFailed: []templateBundleCollectionEntry{},
|
|
Created: []templateBundleApplyEntry{},
|
|
Skipped: []templateBundleApplyEntry{},
|
|
Failed: []templateBundleApplyEntry{},
|
|
SyncQueued: []templateBundleApplyEntry{},
|
|
Featured: []templateBundleFeaturedEntry{},
|
|
FeaturedFailed: []templateBundleFeaturedEntry{},
|
|
}
|
|
|
|
selectedLibraryIDs := make(map[int]struct{}, len(libraryIDs))
|
|
for _, libraryID := range libraryIDs {
|
|
selectedLibraryIDs[libraryID] = struct{}{}
|
|
}
|
|
|
|
collectionsByLibraryID := make(map[int][]*models.LibraryCollection, len(libraryIDs))
|
|
remainingByLibrarySlug := make(map[templateBundleExistingCollectionKey]*models.LibraryCollection)
|
|
reportTemplateBundleApplyProgress(progress, "Checking existing collections")
|
|
for _, libraryID := range libraryIDs {
|
|
collections, err := h.repo.ListByLibrary(ctx, libraryID, catalog.ListLibraryCollectionsOptions{IncludeHidden: true})
|
|
if err != nil {
|
|
library := librariesByID[libraryID]
|
|
if req.DeleteExisting {
|
|
resp.DeleteFailed = append(resp.DeleteFailed, templateBundleCollectionEntry{
|
|
LibraryID: library.ID,
|
|
LibraryName: library.Name,
|
|
Reason: err.Error(),
|
|
})
|
|
} else {
|
|
slog.Warn("listing existing collections before template bundle apply",
|
|
"bundle_id", bundle.ID,
|
|
"library_id", library.ID,
|
|
"error", err,
|
|
)
|
|
}
|
|
continue
|
|
}
|
|
collectionsByLibraryID[libraryID] = collections
|
|
for _, collection := range collections {
|
|
rememberTemplateBundleExistingCollection(remainingByLibrarySlug, libraryID, collection)
|
|
}
|
|
}
|
|
|
|
if req.DeleteExisting {
|
|
if !req.DryRun && h.SectionRepo != nil {
|
|
reportTemplateBundleApplyProgress(progress, "Deleting existing collections")
|
|
// Generated featured sections must go before we delete the
|
|
// collections they reference; otherwise every following delete
|
|
// trips the in-use guard and the admin sees a misleading wall of
|
|
// "collection_in_use" errors.
|
|
if err := h.SectionRepo.DeleteGeneratedTemplateBundleFeaturedSections(ctx, bundle.ID, libraryIDs); err != nil {
|
|
slog.Error("deleting generated template bundle featured sections", "bundle_id", bundle.ID, "error", err)
|
|
return applyTemplateBundleResponse{}, newTemplateBundleApplyError(http.StatusInternalServerError, "delete_setup_failed", "Failed to clear generated featured sections before delete")
|
|
}
|
|
}
|
|
seenCollections := make(map[string]struct{})
|
|
for _, libraryID := range libraryIDs {
|
|
library := librariesByID[libraryID]
|
|
for _, collection := range collectionsByLibraryID[libraryID] {
|
|
if _, ok := seenCollections[collection.ID]; ok {
|
|
continue
|
|
}
|
|
seenCollections[collection.ID] = struct{}{}
|
|
entry := templateBundleCollectionEntryForLibrary(collection, library, "")
|
|
if !allCollectionLibrariesSelected(collection, selectedLibraryIDs) {
|
|
entry.Reason = "shared_with_unselected_library"
|
|
resp.DeleteSkipped = append(resp.DeleteSkipped, entry)
|
|
continue
|
|
}
|
|
if req.DryRun {
|
|
entry.Reason = "would_delete"
|
|
resp.Deleted = append(resp.Deleted, entry)
|
|
continue
|
|
}
|
|
if err := h.deleteServerCollection(ctx, collection.ID); err != nil {
|
|
entry.Reason = err.Error()
|
|
if errors.Is(err, errLibraryCollectionInUse) {
|
|
entry.Reason = "in_use_by_section"
|
|
resp.DeleteSkipped = append(resp.DeleteSkipped, entry)
|
|
} else {
|
|
resp.DeleteFailed = append(resp.DeleteFailed, entry)
|
|
}
|
|
continue
|
|
}
|
|
forgetTemplateBundleExistingCollection(remainingByLibrarySlug, selectedLibraryIDs, collection)
|
|
entry.Reason = "deleted"
|
|
resp.Deleted = append(resp.Deleted, entry)
|
|
}
|
|
}
|
|
}
|
|
|
|
collectionRefs := make(map[templateBundleCollectionRefKey]templateBundleCollectionRef)
|
|
pendingSyncs := make([]pendingTemplateBundleSync, 0)
|
|
reportTemplateBundleApplyProgress(progress, "Creating collection defaults")
|
|
for _, libraryID := range libraryIDs {
|
|
library := librariesByID[libraryID]
|
|
for _, templateID := range bundle.TemplateIDs {
|
|
tmpl, ok := h.templateRegistry().Get(templateID)
|
|
if !ok {
|
|
resp.Failed = append(resp.Failed, templateBundleEntry(tmpl, templateID, library, "", "template_not_found"))
|
|
continue
|
|
}
|
|
entry := templateBundleEntry(tmpl, templateID, library, "", "")
|
|
if !templateEligibleForLibrary(tmpl, library) {
|
|
entry.Reason = "ineligible_library"
|
|
resp.Skipped = append(resp.Skipped, entry)
|
|
continue
|
|
}
|
|
key := templateBundleManagementKey(bundle.ID, templateID, library.ID)
|
|
existing, err := h.repo.GetByManagementKey(ctx, collectionManagementModeTemplateBundle, bundle.ID, key)
|
|
if err == nil {
|
|
if !req.DryRun {
|
|
h.ensureTemplatePoster(ctx, existing, tmpl)
|
|
}
|
|
entry.CollectionID = existing.ID
|
|
entry.Reason = "already_exists"
|
|
resp.Skipped = append(resp.Skipped, entry)
|
|
collectionRefs[templateBundleCollectionRefKey{LibraryID: library.ID, TemplateID: tmpl.ID}] = templateBundleCollectionRef{
|
|
CollectionID: existing.ID,
|
|
Template: tmpl,
|
|
Library: library,
|
|
}
|
|
continue
|
|
}
|
|
if !errors.Is(err, catalog.ErrLibraryCollectionNotFound) {
|
|
entry.Reason = err.Error()
|
|
resp.Failed = append(resp.Failed, entry)
|
|
continue
|
|
}
|
|
// Slug-match adoption fallback. Active in every (DryRun, DeleteExisting)
|
|
// combination EXCEPT (true, true): in a dry-run preview of a
|
|
// delete-and-recreate, the deletion is reported but not executed, so
|
|
// the slug-match map still holds the would-be-deleted collections —
|
|
// adopting them in the preview would misreport the outcome.
|
|
//
|
|
// In the (false, true) case, real deletes have already run. Anything
|
|
// still in remainingByLibrarySlug is a delete failure (typically
|
|
// in_use_by_section). The adoption flags itself with that reason so
|
|
// admins can tell "adopted because delete failed" apart from a
|
|
// straight "already_exists".
|
|
if !(req.DryRun && req.DeleteExisting) {
|
|
existingBySlug := remainingByLibrarySlug[templateBundleExistingCollectionKey{
|
|
LibraryID: library.ID,
|
|
Slug: slugifyCollectionName(tmpl.Title),
|
|
}]
|
|
if existingBySlug != nil {
|
|
if !req.DryRun {
|
|
h.ensureTemplatePoster(ctx, existingBySlug, tmpl)
|
|
}
|
|
entry.CollectionID = existingBySlug.ID
|
|
if req.DeleteExisting {
|
|
entry.Reason = "already_exists_delete_failed"
|
|
} else {
|
|
entry.Reason = "already_exists"
|
|
}
|
|
resp.Skipped = append(resp.Skipped, entry)
|
|
collectionRefs[templateBundleCollectionRefKey{LibraryID: library.ID, TemplateID: tmpl.ID}] = templateBundleCollectionRef{
|
|
CollectionID: existingBySlug.ID,
|
|
Template: tmpl,
|
|
Library: library,
|
|
}
|
|
continue
|
|
}
|
|
}
|
|
if req.DryRun {
|
|
entry.Reason = "would_create"
|
|
resp.Created = append(resp.Created, entry)
|
|
collectionRefs[templateBundleCollectionRefKey{LibraryID: library.ID, TemplateID: tmpl.ID}] = templateBundleCollectionRef{
|
|
Template: tmpl,
|
|
Library: library,
|
|
}
|
|
continue
|
|
}
|
|
|
|
collection, err := h.createCollectionFromTemplate(ctx, bundle.ID, tmpl, library.ID, key)
|
|
if err != nil {
|
|
entry.Reason = err.Error()
|
|
resp.Failed = append(resp.Failed, entry)
|
|
continue
|
|
}
|
|
entry.CollectionID = collection.ID
|
|
rememberTemplateBundleExistingCollection(remainingByLibrarySlug, library.ID, collection)
|
|
if templateBundleTemplateCanInitialSync(tmpl) {
|
|
pendingSyncs = append(pendingSyncs, pendingTemplateBundleSync{
|
|
CollectionID: collection.ID,
|
|
SyncSchedule: collection.SyncSchedule,
|
|
Entry: entry,
|
|
})
|
|
} else {
|
|
entry.Reason = "created_sync_skipped_unconfigured"
|
|
resp.Created = append(resp.Created, entry)
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(pendingSyncs) > 0 {
|
|
var created []templateBundleApplyEntry
|
|
// Async path: the HTTP response returns before syncs complete, so per-item
|
|
// failures (TMDB rate-limit, missing API key, placeholder source config)
|
|
// won't appear in resp.Failed. Each failed sync still produces a
|
|
// library_collection_sync_runs row with status='failed', and every
|
|
// collection in this bundle is queryable by management_source=bundle.ID —
|
|
// the admin UI polls those for status after a bulk apply.
|
|
if shouldQueueTemplateBundleSyncs(bundle, len(pendingSyncs)) {
|
|
reportTemplateBundleApplyProgress(progress, "Queueing initial syncs")
|
|
created = templateBundleCreatedEntries(pendingSyncs)
|
|
queued := templateBundleQueuedEntries(pendingSyncs)
|
|
resp.Created = append(resp.Created, created...)
|
|
resp.SyncQueued = append(resp.SyncQueued, queued...)
|
|
h.syncTemplateBundleCollectionsAsync(bundle.ID, pendingSyncs)
|
|
} else {
|
|
var failed []templateBundleApplyEntry
|
|
created, failed = h.syncTemplateBundleCollections(ctx, pendingSyncs)
|
|
resp.Created = append(resp.Created, created...)
|
|
resp.Failed = append(resp.Failed, failed...)
|
|
h.generateTemplateBundleCollagesAsync(created)
|
|
}
|
|
for _, entry := range created {
|
|
library := librariesByID[entry.LibraryID]
|
|
tmpl, _ := h.templateRegistry().Get(entry.TemplateID)
|
|
collectionRefs[templateBundleCollectionRefKey{LibraryID: entry.LibraryID, TemplateID: entry.TemplateID}] = templateBundleCollectionRef{
|
|
CollectionID: entry.CollectionID,
|
|
Template: tmpl,
|
|
Library: library,
|
|
}
|
|
}
|
|
}
|
|
|
|
if req.Featured != nil {
|
|
reportTemplateBundleApplyProgress(progress, "Creating featured sections")
|
|
featured, failed := h.applyTemplateBundleFeaturedSections(
|
|
ctx,
|
|
bundle,
|
|
req.Featured,
|
|
librariesByID,
|
|
collectionRefs,
|
|
req.DryRun,
|
|
)
|
|
resp.Featured = append(resp.Featured, featured...)
|
|
resp.FeaturedFailed = append(resp.FeaturedFailed, failed...)
|
|
}
|
|
|
|
reportTemplateBundleApplyProgress(progress, "Collection defaults applied")
|
|
return resp, nil
|
|
}
|
|
|
|
func reportTemplateBundleApplyProgress(progress func(current, total int, message string), message string) {
|
|
if progress != nil {
|
|
progress(0, 0, message)
|
|
}
|
|
}
|
|
|
|
func (h *LibraryCollectionHandler) ensureTemplatePoster(
|
|
ctx context.Context,
|
|
collection *models.LibraryCollection,
|
|
tmpl templates.Template,
|
|
) {
|
|
posterPath := strings.TrimSpace(tmpl.PosterPath)
|
|
if posterPath == "" || collection == nil {
|
|
return
|
|
}
|
|
// Refresh only when the existing poster came from an auto-generated
|
|
// collage or a prior template apply. Admin-uploaded posters
|
|
// (auto_generated=false AND from_template=false) are sticky and must not
|
|
// be overwritten by a subsequent bundle apply.
|
|
if collection.PosterURL != "" && !collection.PosterAutoGenerated && !collection.PosterFromTemplate {
|
|
return
|
|
}
|
|
if collection.PosterURL == posterPath && collection.PosterFromTemplate {
|
|
return
|
|
}
|
|
if stored, _, _, err := h.storeBundledTemplatePoster(ctx, collection.ID, posterPath, true); err != nil {
|
|
slog.Warn("failed to store template poster",
|
|
"collection_id", collection.ID,
|
|
"template_id", tmpl.ID,
|
|
"poster_path", posterPath,
|
|
"error", err,
|
|
)
|
|
return
|
|
} else if stored {
|
|
return
|
|
}
|
|
|
|
emptyThumbhash := ""
|
|
notAutoGenerated := false
|
|
fromTemplate := true
|
|
if err := h.repo.Update(ctx, catalog.UpdateLibraryCollectionInput{
|
|
ID: collection.ID,
|
|
PosterURL: &posterPath,
|
|
PosterThumbhash: &emptyThumbhash,
|
|
PosterAutoGenerated: ¬AutoGenerated,
|
|
PosterFromTemplate: &fromTemplate,
|
|
}); err != nil {
|
|
slog.Warn("failed to apply template poster",
|
|
"collection_id", collection.ID,
|
|
"template_id", tmpl.ID,
|
|
"poster_path", posterPath,
|
|
"error", err,
|
|
)
|
|
}
|
|
}
|
|
|
|
func shouldQueueTemplateBundleSyncs(bundle templates.Bundle, pendingCount int) bool {
|
|
return bundle.ID == "all_defaults" || pendingCount > templateBundleInlineSyncLimit
|
|
}
|
|
|
|
func templateBundleTemplateCanInitialSync(tmpl templates.Template) bool {
|
|
if tmpl.Source == templates.SourceTMDBCollection {
|
|
return tmpl.TMDBCollection != nil && tmpl.TMDBCollection.CollectionID > 0
|
|
}
|
|
return true
|
|
}
|
|
|
|
func templateBundleCreatedEntries(pending []pendingTemplateBundleSync) []templateBundleApplyEntry {
|
|
entries := make([]templateBundleApplyEntry, 0, len(pending))
|
|
for _, item := range pending {
|
|
entries = append(entries, item.Entry)
|
|
}
|
|
return entries
|
|
}
|
|
|
|
// templateBundleQueuedEntries marks each pending sync with a reason describing
|
|
// what the background goroutine will do with it. Items without a cron schedule
|
|
// can still receive one initial async sync attempt when their source config is
|
|
// complete, but the catalog scheduler won't re-fire them.
|
|
//
|
|
// Deliberately does not touch next_sync_at: setting it to now would race with
|
|
// the catalog sync daemon picking these rows up mid-flight; the async
|
|
// goroutine's advanceTemplateBundleSyncSchedules call is the single writer.
|
|
func templateBundleQueuedEntries(pending []pendingTemplateBundleSync) []templateBundleApplyEntry {
|
|
entries := make([]templateBundleApplyEntry, 0, len(pending))
|
|
for _, item := range pending {
|
|
entry := item.Entry
|
|
if item.SyncSchedule == nil || strings.TrimSpace(*item.SyncSchedule) == "" {
|
|
entry.Reason = "sync_queued_no_schedule"
|
|
} else {
|
|
entry.Reason = "sync_queued"
|
|
}
|
|
entries = append(entries, entry)
|
|
}
|
|
return entries
|
|
}
|
|
|
|
func (h *LibraryCollectionHandler) syncTemplateBundleCollectionsAsync(
|
|
bundleID string,
|
|
pending []pendingTemplateBundleSync,
|
|
) {
|
|
if h.service == nil || len(pending) == 0 {
|
|
return
|
|
}
|
|
pending = append([]pendingTemplateBundleSync(nil), pending...)
|
|
go func() {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
slog.Error("template bundle async sync panic",
|
|
"bundle_id", bundleID,
|
|
"pending", len(pending),
|
|
"panic", r,
|
|
"stack", string(debug.Stack()),
|
|
)
|
|
}
|
|
}()
|
|
ctx, cancel := context.WithTimeout(context.Background(), templateBundleAsyncSyncTimeout)
|
|
defer cancel()
|
|
|
|
startedAt := time.Now()
|
|
created, failed := h.syncTemplateBundleCollections(ctx, pending)
|
|
h.generateTemplateBundleCollagesAsync(created)
|
|
h.advanceTemplateBundleSyncSchedules(ctx, pending)
|
|
|
|
slog.Info("template bundle async sync complete",
|
|
"bundle_id", bundleID,
|
|
"pending", len(pending),
|
|
"synced", len(created),
|
|
"failed", len(failed),
|
|
"duration", time.Since(startedAt).Round(time.Millisecond),
|
|
)
|
|
for _, entry := range failed {
|
|
slog.Warn("template bundle async sync failed",
|
|
"bundle_id", bundleID,
|
|
"collection_id", entry.CollectionID,
|
|
"template_id", entry.TemplateID,
|
|
"library_id", entry.LibraryID,
|
|
"reason", entry.Reason,
|
|
)
|
|
}
|
|
}()
|
|
}
|
|
|
|
func (h *LibraryCollectionHandler) advanceTemplateBundleSyncSchedules(
|
|
ctx context.Context,
|
|
pending []pendingTemplateBundleSync,
|
|
) {
|
|
now := time.Now()
|
|
for _, item := range pending {
|
|
if item.SyncSchedule == nil || strings.TrimSpace(*item.SyncSchedule) == "" {
|
|
continue
|
|
}
|
|
next := catalog.ComputeNextSyncAtFrom(*item.SyncSchedule, now)
|
|
if err := h.repo.UpdateNextSyncAt(ctx, item.CollectionID, next); err != nil {
|
|
slog.Warn("template bundle async sync: failed to advance schedule",
|
|
"collection_id", item.CollectionID,
|
|
"template_id", item.Entry.TemplateID,
|
|
"error", err,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (h *LibraryCollectionHandler) syncTemplateBundleCollections(
|
|
ctx context.Context,
|
|
pending []pendingTemplateBundleSync,
|
|
) ([]templateBundleApplyEntry, []templateBundleApplyEntry) {
|
|
type syncResult struct {
|
|
entry templateBundleApplyEntry
|
|
err error
|
|
}
|
|
results := make([]syncResult, len(pending))
|
|
var eg errgroup.Group
|
|
eg.SetLimit(templateBundleSyncConcurrency)
|
|
for i, item := range pending {
|
|
i, item := i, item
|
|
eg.Go(func() (err error) {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
slog.Error("template bundle sync worker panic",
|
|
"collection_id", item.CollectionID,
|
|
"template_id", item.Entry.TemplateID,
|
|
"panic", r,
|
|
"stack", string(debug.Stack()),
|
|
)
|
|
err = fmt.Errorf("sync worker panicked: %v", r)
|
|
results[i] = syncResult{entry: item.Entry, err: err}
|
|
}
|
|
}()
|
|
_, err = h.service.SyncCollectionWithOptions(ctx, item.CollectionID, catalog.SyncCollectionOptions{
|
|
SkipCollage: true,
|
|
})
|
|
results[i] = syncResult{entry: item.Entry, err: err}
|
|
return nil
|
|
})
|
|
}
|
|
_ = eg.Wait()
|
|
|
|
created := make([]templateBundleApplyEntry, 0, len(pending))
|
|
failed := make([]templateBundleApplyEntry, 0)
|
|
for _, result := range results {
|
|
if result.err != nil {
|
|
result.entry.Reason = result.err.Error()
|
|
failed = append(failed, result.entry)
|
|
continue
|
|
}
|
|
created = append(created, result.entry)
|
|
}
|
|
return created, failed
|
|
}
|
|
|
|
func (h *LibraryCollectionHandler) generateTemplateBundleCollagesAsync(entries []templateBundleApplyEntry) {
|
|
if h.service == nil || h.service.CollageGen == nil || len(entries) == 0 {
|
|
return
|
|
}
|
|
collectionIDs := make([]string, 0, len(entries))
|
|
for _, entry := range entries {
|
|
if entry.CollectionID != "" {
|
|
collectionIDs = append(collectionIDs, entry.CollectionID)
|
|
}
|
|
}
|
|
if len(collectionIDs) == 0 {
|
|
return
|
|
}
|
|
|
|
go func() {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
slog.Error("template bundle collage goroutine panic",
|
|
"collection_count", len(collectionIDs),
|
|
"panic", r,
|
|
"stack", string(debug.Stack()),
|
|
)
|
|
}
|
|
}()
|
|
ctx, cancel := context.WithTimeout(context.Background(), templateBundleCollageTimeout)
|
|
defer cancel()
|
|
|
|
var eg errgroup.Group
|
|
eg.SetLimit(templateBundleCollageConcurrency)
|
|
for _, collectionID := range collectionIDs {
|
|
collectionID := collectionID
|
|
eg.Go(func() (err error) {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
slog.Error("collage worker panic",
|
|
"collection_id", collectionID,
|
|
"panic", r,
|
|
"stack", string(debug.Stack()),
|
|
)
|
|
err = nil // swallow; collage failures must not abort the batch
|
|
}
|
|
}()
|
|
collection, err := h.repo.GetByID(ctx, collectionID)
|
|
if err != nil {
|
|
slog.Warn("collage: failed to load collection", "collection_id", collectionID, "error", err)
|
|
return nil
|
|
}
|
|
if collection.PosterURL != "" && !collection.PosterAutoGenerated {
|
|
return nil
|
|
}
|
|
if err := h.service.CollageGen.GenerateCollectionPoster(ctx, collectionID); err != nil {
|
|
if errors.Is(err, collage.ErrNotEnoughImages) {
|
|
slog.Debug("collage: not enough images", "collection_id", collectionID)
|
|
} else {
|
|
slog.Warn("collage: async poster generation failed", "collection_id", collectionID, "error", err)
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
_ = eg.Wait()
|
|
}()
|
|
}
|
|
|
|
func (h *LibraryCollectionHandler) applyTemplateBundleFeaturedSections(
|
|
ctx context.Context,
|
|
bundle templates.Bundle,
|
|
req *templateBundleFeaturedRequest,
|
|
librariesByID map[int]*models.MediaFolder,
|
|
collectionRefs map[templateBundleCollectionRefKey]templateBundleCollectionRef,
|
|
dryRun bool,
|
|
) ([]templateBundleFeaturedEntry, []templateBundleFeaturedEntry) {
|
|
if req == nil {
|
|
return nil, nil
|
|
}
|
|
featured := make([]templateBundleFeaturedEntry, 0)
|
|
failed := make([]templateBundleFeaturedEntry, 0)
|
|
|
|
if req.Home != nil && strings.TrimSpace(req.Home.TemplateID) != "" {
|
|
ok, fail := h.applyTemplateBundleFeaturedSelection(ctx, bundle, "home", req.Home.LibraryID, req.Home.TemplateID, librariesByID, collectionRefs, dryRun)
|
|
if fail != nil {
|
|
failed = append(failed, *fail)
|
|
} else if ok != nil {
|
|
featured = append(featured, *ok)
|
|
}
|
|
}
|
|
|
|
libraryIDs := make([]int, 0, len(req.Libraries))
|
|
for libraryID := range req.Libraries {
|
|
libraryIDs = append(libraryIDs, libraryID)
|
|
}
|
|
sort.Ints(libraryIDs)
|
|
for _, libraryID := range libraryIDs {
|
|
templateID := strings.TrimSpace(req.Libraries[libraryID])
|
|
if templateID == "" {
|
|
continue
|
|
}
|
|
ok, fail := h.applyTemplateBundleFeaturedSelection(ctx, bundle, "library", libraryID, templateID, librariesByID, collectionRefs, dryRun)
|
|
if fail != nil {
|
|
failed = append(failed, *fail)
|
|
continue
|
|
}
|
|
if ok != nil {
|
|
featured = append(featured, *ok)
|
|
}
|
|
}
|
|
|
|
return featured, failed
|
|
}
|
|
|
|
func (h *LibraryCollectionHandler) applyTemplateBundleFeaturedSelection(
|
|
ctx context.Context,
|
|
bundle templates.Bundle,
|
|
surface string,
|
|
libraryID int,
|
|
templateID string,
|
|
librariesByID map[int]*models.MediaFolder,
|
|
collectionRefs map[templateBundleCollectionRefKey]templateBundleCollectionRef,
|
|
dryRun bool,
|
|
) (*templateBundleFeaturedEntry, *templateBundleFeaturedEntry) {
|
|
library := librariesByID[libraryID]
|
|
tmpl, ok := h.templateRegistry().Get(templateID)
|
|
entry := templateBundleFeaturedEntry{
|
|
Surface: surface,
|
|
LibraryID: libraryID,
|
|
TemplateID: templateID,
|
|
}
|
|
if library != nil {
|
|
entry.LibraryName = library.Name
|
|
}
|
|
if ok {
|
|
entry.TemplateTitle = tmpl.Title
|
|
}
|
|
|
|
fail := func(reason string) (*templateBundleFeaturedEntry, *templateBundleFeaturedEntry) {
|
|
entry.Reason = reason
|
|
return nil, &entry
|
|
}
|
|
|
|
if library == nil {
|
|
return fail("library_not_selected")
|
|
}
|
|
if !ok {
|
|
return fail("template_not_found")
|
|
}
|
|
if !slices.Contains(bundle.TemplateIDs, templateID) {
|
|
return fail("template_not_in_bundle")
|
|
}
|
|
if !templateEligibleForLibrary(tmpl, library) {
|
|
return fail("ineligible_library")
|
|
}
|
|
ref, ok := collectionRefs[templateBundleCollectionRefKey{LibraryID: libraryID, TemplateID: templateID}]
|
|
if !ok {
|
|
return fail("collection_not_available")
|
|
}
|
|
entry.CollectionID = ref.CollectionID
|
|
if dryRun {
|
|
entry.Reason = "would_create"
|
|
return &entry, nil
|
|
}
|
|
if h.SectionRepo == nil {
|
|
return fail("section_repo_not_configured")
|
|
}
|
|
|
|
sectionID, err := h.upsertTemplateBundleFeaturedSection(ctx, bundle.ID, surface, ref)
|
|
if err != nil {
|
|
return fail(err.Error())
|
|
}
|
|
entry.SectionID = sectionID
|
|
entry.Reason = "created"
|
|
return &entry, nil
|
|
}
|
|
|
|
func (h *LibraryCollectionHandler) upsertTemplateBundleFeaturedSection(
|
|
ctx context.Context,
|
|
bundleID string,
|
|
surface string,
|
|
ref templateBundleCollectionRef,
|
|
) (string, error) {
|
|
if h.SectionRepo == nil {
|
|
return "", fmt.Errorf("section repo not configured")
|
|
}
|
|
if ref.Library == nil {
|
|
return "", fmt.Errorf("library not available")
|
|
}
|
|
if ref.CollectionID == "" {
|
|
return "", fmt.Errorf("collection not available")
|
|
}
|
|
|
|
scope := "home"
|
|
var sectionLibraryID *int
|
|
if surface == "library" {
|
|
scope = "library"
|
|
libraryID := ref.Library.ID
|
|
sectionLibraryID = &libraryID
|
|
}
|
|
|
|
config, err := templateBundleFeaturedSectionConfig(bundleID, surface, ref)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
existing, err := h.SectionRepo.GetGeneratedTemplateBundleFeaturedSection(ctx, bundleID, scope, sectionLibraryID)
|
|
if err == nil {
|
|
existing.SectionType = sections.SectionCollection
|
|
existing.Title = ref.Template.Title
|
|
existing.Featured = true
|
|
existing.ItemLimit = templateBundleFeaturedSectionItemLimit
|
|
existing.Config = config
|
|
existing.Enabled = true
|
|
if err := h.SectionRepo.Update(ctx, existing); err != nil {
|
|
return "", err
|
|
}
|
|
if err := h.SectionRepo.ClearFeaturedForSurface(ctx, scope, sectionLibraryID, existing.ID); err != nil {
|
|
return "", err
|
|
}
|
|
return existing.ID, nil
|
|
}
|
|
if !errors.Is(err, sections.ErrSectionNotFound) {
|
|
return "", err
|
|
}
|
|
|
|
created, err := h.SectionRepo.Create(ctx, §ions.PageSection{
|
|
Scope: scope,
|
|
LibraryID: sectionLibraryID,
|
|
Position: 0,
|
|
SectionType: sections.SectionCollection,
|
|
Title: ref.Template.Title,
|
|
Featured: true,
|
|
ItemLimit: templateBundleFeaturedSectionItemLimit,
|
|
Config: config,
|
|
Enabled: true,
|
|
})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if err := h.SectionRepo.ClearFeaturedForSurface(ctx, scope, sectionLibraryID, created.ID); err != nil {
|
|
return "", err
|
|
}
|
|
return created.ID, nil
|
|
}
|
|
|
|
const templateBundleFeaturedSectionItemLimit = 12
|
|
|
|
func templateBundleFeaturedSectionConfig(
|
|
bundleID string,
|
|
surface string,
|
|
ref templateBundleCollectionRef,
|
|
) (json.RawMessage, error) {
|
|
payload := map[string]any{
|
|
"library_collection_id": ref.CollectionID,
|
|
"generated_source": "template_bundle_featured",
|
|
"template_bundle": bundleID,
|
|
"template_id": ref.Template.ID,
|
|
"surface": surface,
|
|
"library_id": ref.Library.ID,
|
|
}
|
|
raw, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("building featured section config: %w", err)
|
|
}
|
|
return raw, nil
|
|
}
|
|
|
|
func (h *LibraryCollectionHandler) createCollectionFromTemplate(
|
|
ctx context.Context,
|
|
bundleID string,
|
|
tmpl templates.Template,
|
|
libraryID int,
|
|
managementKey string,
|
|
) (*models.LibraryCollection, error) {
|
|
limit := templateLimitPtr(tmpl)
|
|
switch tmpl.Source {
|
|
case templates.SourceTMDB:
|
|
if tmpl.TMDB == nil {
|
|
return nil, fmt.Errorf("template %q is missing TMDB config", tmpl.ID)
|
|
}
|
|
return h.createTMDBCollection(ctx, importTMDBRequest{
|
|
LibraryID: libraryID,
|
|
Title: tmpl.Title,
|
|
Description: tmpl.Description,
|
|
Preset: tmpl.TMDB.Preset,
|
|
TimeWindow: tmpl.TMDB.TimeWindow,
|
|
MediaType: tmpl.TMDB.MediaType,
|
|
Limit: limit,
|
|
Featured: tmpl.Featured,
|
|
SortOrder: tmpl.DefaultSortOrder,
|
|
PosterURL: tmpl.PosterPath,
|
|
SyncSchedule: tmpl.DefaultSyncSchedule,
|
|
ManagementMode: collectionManagementModeTemplateBundle,
|
|
ManagementSource: bundleID,
|
|
ManagementKey: managementKey,
|
|
PosterFromTemplate: tmpl.PosterPath != "",
|
|
})
|
|
case templates.SourceMDBList:
|
|
if tmpl.MDBList == nil || strings.TrimSpace(tmpl.MDBList.URL) == "" {
|
|
return nil, fmt.Errorf("template %q is missing MDBList URL", tmpl.ID)
|
|
}
|
|
return h.createMDBListCollection(ctx, importMDBListRequest{
|
|
LibraryID: libraryID,
|
|
Title: tmpl.Title,
|
|
Description: tmpl.Description,
|
|
URL: tmpl.MDBList.URL,
|
|
Limit: limit,
|
|
Featured: tmpl.Featured,
|
|
SortOrder: tmpl.DefaultSortOrder,
|
|
PosterURL: tmpl.PosterPath,
|
|
SyncSchedule: tmpl.DefaultSyncSchedule,
|
|
ManagementMode: collectionManagementModeTemplateBundle,
|
|
ManagementSource: bundleID,
|
|
ManagementKey: managementKey,
|
|
PosterFromTemplate: tmpl.PosterPath != "",
|
|
})
|
|
case templates.SourceTMDBCollection:
|
|
if tmpl.TMDBCollection == nil {
|
|
return nil, fmt.Errorf("template %q is missing TMDBCollection config", tmpl.ID)
|
|
}
|
|
return h.createTMDBFranchiseCollection(ctx, importTMDBFranchiseRequest{
|
|
LibraryID: libraryID,
|
|
Title: tmpl.Title,
|
|
Description: tmpl.Description,
|
|
CollectionID: tmpl.TMDBCollection.CollectionID,
|
|
Limit: limit,
|
|
Featured: tmpl.Featured,
|
|
SortOrder: tmpl.DefaultSortOrder,
|
|
PosterURL: tmpl.PosterPath,
|
|
SyncSchedule: tmpl.DefaultSyncSchedule,
|
|
ManagementMode: collectionManagementModeTemplateBundle,
|
|
ManagementSource: bundleID,
|
|
ManagementKey: managementKey,
|
|
PosterFromTemplate: tmpl.PosterPath != "",
|
|
})
|
|
case templates.SourceTMDBDiscover:
|
|
if tmpl.TMDBDiscover == nil {
|
|
return nil, fmt.Errorf("template %q is missing TMDBDiscover config", tmpl.ID)
|
|
}
|
|
return h.createTMDBDiscoverCollection(ctx, importTMDBDiscoverRequest{
|
|
LibraryID: libraryID,
|
|
Title: tmpl.Title,
|
|
Description: tmpl.Description,
|
|
MediaType: tmpl.TMDBDiscover.MediaType,
|
|
Spec: importTMDBDiscoverSpecBody{
|
|
WithGenres: tmpl.TMDBDiscover.WithGenres,
|
|
WithoutGenres: tmpl.TMDBDiscover.WithoutGenres,
|
|
SortBy: tmpl.TMDBDiscover.SortBy,
|
|
VoteCountGte: tmpl.TMDBDiscover.VoteCountGte,
|
|
VoteAverageGte: tmpl.TMDBDiscover.VoteAverageGte,
|
|
ReleaseDateGte: tmpl.TMDBDiscover.ReleaseDateGte,
|
|
ReleaseDateLte: tmpl.TMDBDiscover.ReleaseDateLte,
|
|
Certifications: tmpl.TMDBDiscover.Certifications,
|
|
CertificationLte: tmpl.TMDBDiscover.CertificationLte,
|
|
WithRuntimeGte: tmpl.TMDBDiscover.WithRuntimeGte,
|
|
WithRuntimeLte: tmpl.TMDBDiscover.WithRuntimeLte,
|
|
OriginalLanguage: tmpl.TMDBDiscover.OriginalLanguage,
|
|
},
|
|
Limit: limit,
|
|
Featured: tmpl.Featured,
|
|
SortOrder: tmpl.DefaultSortOrder,
|
|
PosterURL: tmpl.PosterPath,
|
|
SyncSchedule: tmpl.DefaultSyncSchedule,
|
|
ManagementMode: collectionManagementModeTemplateBundle,
|
|
ManagementSource: bundleID,
|
|
ManagementKey: managementKey,
|
|
PosterFromTemplate: tmpl.PosterPath != "",
|
|
})
|
|
default:
|
|
return nil, fmt.Errorf("template source %q is not supported by bundles", tmpl.Source)
|
|
}
|
|
}
|
|
|
|
func (h *LibraryCollectionHandler) createMDBListCollection(
|
|
ctx context.Context,
|
|
req importMDBListRequest,
|
|
) (*models.LibraryCollection, error) {
|
|
var syncSchedule *string
|
|
if s := strings.TrimSpace(req.SyncSchedule); s != "" {
|
|
if err := catalog.ParseCronExpression(s); err != nil {
|
|
return nil, requestValidationError{err: err}
|
|
}
|
|
syncSchedule = &s
|
|
}
|
|
|
|
normalizedURL := usercollections.NormalizeMDBListURL(req.URL)
|
|
sourceConfig, err := buildMDBListSourceConfig(normalizedURL, req.Limit)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("building MDBList source config: %w", err)
|
|
}
|
|
managementMode, managementSource, managementKey, err := normalizeCollectionManagementFields(req.ManagementMode, req.ManagementSource, req.ManagementKey)
|
|
if err != nil {
|
|
return nil, requestValidationError{err: err}
|
|
}
|
|
collection, err := h.repo.Create(ctx, catalog.CreateLibraryCollectionInput{
|
|
LibraryID: req.LibraryID,
|
|
LibraryIDs: req.LibraryIDs,
|
|
Slug: slugifyCollectionName(req.Title),
|
|
Title: req.Title,
|
|
Description: req.Description,
|
|
CollectionType: "mdblist",
|
|
Visibility: "visible",
|
|
Featured: req.Featured,
|
|
SortOrder: req.SortOrder,
|
|
PosterURL: req.PosterURL,
|
|
PosterFromTemplate: req.PosterFromTemplate,
|
|
SourceURL: req.URL,
|
|
SourceConfig: sourceConfig,
|
|
ManagementMode: managementMode,
|
|
ManagementSource: managementSource,
|
|
ManagementKey: managementKey,
|
|
SyncSchedule: syncSchedule,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("creating collection: %w", err)
|
|
}
|
|
if stored, path, thumbhash, err := h.storeBundledTemplatePoster(ctx, collection.ID, req.PosterURL, req.PosterFromTemplate); err != nil {
|
|
return nil, err
|
|
} else if stored {
|
|
collection.PosterURL = path
|
|
collection.PosterThumbhash = thumbhash
|
|
collection.PosterAutoGenerated = false
|
|
if req.PosterFromTemplate {
|
|
collection.PosterFromTemplate = true
|
|
}
|
|
}
|
|
return collection, nil
|
|
}
|
|
|
|
func (h *LibraryCollectionHandler) createTMDBCollection(
|
|
ctx context.Context,
|
|
req importTMDBRequest,
|
|
) (*models.LibraryCollection, error) {
|
|
preset, mediaType, timeWindow, err := normalizeTMDBPresetRequest(req.Preset, req.MediaType, req.TimeWindow)
|
|
if err != nil {
|
|
return nil, requestValidationError{err: err}
|
|
}
|
|
|
|
var syncSchedule *string
|
|
if s := strings.TrimSpace(req.SyncSchedule); s != "" {
|
|
if err := catalog.ParseCronExpression(s); err != nil {
|
|
return nil, requestValidationError{err: err}
|
|
}
|
|
syncSchedule = &s
|
|
}
|
|
|
|
sourceConfig, err := buildTMDBSourceConfig(preset, mediaType, timeWindow, req.Limit)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("building TMDB source config: %w", err)
|
|
}
|
|
managementMode, managementSource, managementKey, err := normalizeCollectionManagementFields(req.ManagementMode, req.ManagementSource, req.ManagementKey)
|
|
if err != nil {
|
|
return nil, requestValidationError{err: err}
|
|
}
|
|
collection, err := h.repo.Create(ctx, catalog.CreateLibraryCollectionInput{
|
|
LibraryID: req.LibraryID,
|
|
LibraryIDs: req.LibraryIDs,
|
|
Slug: slugifyCollectionName(req.Title),
|
|
Title: req.Title,
|
|
Description: req.Description,
|
|
CollectionType: "tmdb",
|
|
Visibility: "visible",
|
|
Featured: req.Featured,
|
|
SortOrder: req.SortOrder,
|
|
PosterURL: req.PosterURL,
|
|
PosterFromTemplate: req.PosterFromTemplate,
|
|
SourceURL: buildTMDBSourceURL(preset, mediaType, timeWindow),
|
|
SourceConfig: sourceConfig,
|
|
ManagementMode: managementMode,
|
|
ManagementSource: managementSource,
|
|
ManagementKey: managementKey,
|
|
SyncSchedule: syncSchedule,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("creating collection: %w", err)
|
|
}
|
|
if stored, path, thumbhash, err := h.storeBundledTemplatePoster(ctx, collection.ID, req.PosterURL, req.PosterFromTemplate); err != nil {
|
|
return nil, err
|
|
} else if stored {
|
|
collection.PosterURL = path
|
|
collection.PosterThumbhash = thumbhash
|
|
collection.PosterAutoGenerated = false
|
|
if req.PosterFromTemplate {
|
|
collection.PosterFromTemplate = true
|
|
}
|
|
}
|
|
return collection, nil
|
|
}
|
|
|
|
// createTMDBFranchiseCollection persists a TMDB-franchise-backed library
|
|
// collection. It re-uses collection_type "tmdb" (set on the existing TMDB
|
|
// preset path) so listing/admin filters that group by collection_type don't
|
|
// need to learn a new discriminator — the source distinction lives entirely
|
|
// in source_config.mode.
|
|
//
|
|
// A CollectionID of 0 is allowed at create time: this is how the bundle-apply
|
|
// flow materializes the catalog's generic "TMDB Franchise" placeholder
|
|
// template. The first sync against such a row records a failed run with a
|
|
// human-readable explanation prompting the admin to edit the config.
|
|
func (h *LibraryCollectionHandler) createTMDBFranchiseCollection(
|
|
ctx context.Context,
|
|
req importTMDBFranchiseRequest,
|
|
) (*models.LibraryCollection, error) {
|
|
if req.CollectionID < 0 {
|
|
return nil, requestValidationError{err: fmt.Errorf("collection_id must be >= 0")}
|
|
}
|
|
|
|
var syncSchedule *string
|
|
if s := strings.TrimSpace(req.SyncSchedule); s != "" {
|
|
if err := catalog.ParseCronExpression(s); err != nil {
|
|
return nil, requestValidationError{err: err}
|
|
}
|
|
syncSchedule = &s
|
|
}
|
|
|
|
sourceConfig, err := buildTMDBCollectionSourceConfig(req.CollectionID, req.Limit)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("building TMDB franchise source config: %w", err)
|
|
}
|
|
managementMode, managementSource, managementKey, err := normalizeCollectionManagementFields(req.ManagementMode, req.ManagementSource, req.ManagementKey)
|
|
if err != nil {
|
|
return nil, requestValidationError{err: err}
|
|
}
|
|
collection, err := h.repo.Create(ctx, catalog.CreateLibraryCollectionInput{
|
|
LibraryID: req.LibraryID,
|
|
LibraryIDs: req.LibraryIDs,
|
|
Slug: slugifyCollectionName(req.Title),
|
|
Title: req.Title,
|
|
Description: req.Description,
|
|
CollectionType: "tmdb",
|
|
Visibility: "visible",
|
|
Featured: req.Featured,
|
|
SortOrder: req.SortOrder,
|
|
PosterURL: req.PosterURL,
|
|
PosterFromTemplate: req.PosterFromTemplate,
|
|
SourceURL: buildTMDBCollectionSourceURL(req.CollectionID),
|
|
SourceConfig: sourceConfig,
|
|
ManagementMode: managementMode,
|
|
ManagementSource: managementSource,
|
|
ManagementKey: managementKey,
|
|
SyncSchedule: syncSchedule,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("creating collection: %w", err)
|
|
}
|
|
if stored, path, thumbhash, err := h.storeBundledTemplatePoster(ctx, collection.ID, req.PosterURL, req.PosterFromTemplate); err != nil {
|
|
return nil, err
|
|
} else if stored {
|
|
collection.PosterURL = path
|
|
collection.PosterThumbhash = thumbhash
|
|
collection.PosterAutoGenerated = false
|
|
if req.PosterFromTemplate {
|
|
collection.PosterFromTemplate = true
|
|
}
|
|
}
|
|
return collection, nil
|
|
}
|
|
|
|
// createTMDBDiscoverCollection persists a TMDB-discover-backed library
|
|
// collection. Like createTMDBFranchiseCollection, it re-uses collection_type
|
|
// "tmdb" so admin listings don't need to learn another discriminator — the
|
|
// discover specialization lives entirely in source_config.mode.
|
|
func (h *LibraryCollectionHandler) createTMDBDiscoverCollection(
|
|
ctx context.Context,
|
|
req importTMDBDiscoverRequest,
|
|
) (*models.LibraryCollection, error) {
|
|
if err := validateTMDBDiscoverRequest(req); err != nil {
|
|
return nil, requestValidationError{err: err}
|
|
}
|
|
|
|
var syncSchedule *string
|
|
if s := strings.TrimSpace(req.SyncSchedule); s != "" {
|
|
if err := catalog.ParseCronExpression(s); err != nil {
|
|
return nil, requestValidationError{err: err}
|
|
}
|
|
syncSchedule = &s
|
|
}
|
|
|
|
sourceConfig, err := buildTMDBDiscoverSourceConfig(req.MediaType, req.Spec, req.Limit)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("building TMDB discover source config: %w", err)
|
|
}
|
|
managementMode, managementSource, managementKey, err := normalizeCollectionManagementFields(req.ManagementMode, req.ManagementSource, req.ManagementKey)
|
|
if err != nil {
|
|
return nil, requestValidationError{err: err}
|
|
}
|
|
collection, err := h.repo.Create(ctx, catalog.CreateLibraryCollectionInput{
|
|
LibraryID: req.LibraryID,
|
|
LibraryIDs: req.LibraryIDs,
|
|
Slug: slugifyCollectionName(req.Title),
|
|
Title: req.Title,
|
|
Description: req.Description,
|
|
CollectionType: "tmdb",
|
|
Visibility: "visible",
|
|
Featured: req.Featured,
|
|
SortOrder: req.SortOrder,
|
|
PosterURL: req.PosterURL,
|
|
PosterFromTemplate: req.PosterFromTemplate,
|
|
SourceURL: buildTMDBDiscoverSourceURL(req.MediaType, req.Spec.SortBy),
|
|
SourceConfig: sourceConfig,
|
|
ManagementMode: managementMode,
|
|
ManagementSource: managementSource,
|
|
ManagementKey: managementKey,
|
|
SyncSchedule: syncSchedule,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("creating collection: %w", err)
|
|
}
|
|
if stored, path, thumbhash, err := h.storeBundledTemplatePoster(ctx, collection.ID, req.PosterURL, req.PosterFromTemplate); err != nil {
|
|
return nil, err
|
|
} else if stored {
|
|
collection.PosterURL = path
|
|
collection.PosterThumbhash = thumbhash
|
|
collection.PosterAutoGenerated = false
|
|
if req.PosterFromTemplate {
|
|
collection.PosterFromTemplate = true
|
|
}
|
|
}
|
|
return collection, nil
|
|
}
|
|
|
|
// validateTMDBDiscoverRequest performs basic sanity checks on the request
|
|
// body. The deeper spec validation (sort_by, date formats, etc.) lives on
|
|
// templates.validateTMDBDiscover; reuse via importTMDBDiscoverSpecBody isn't
|
|
// straightforward, so this duplicates the most user-facing checks and leaves
|
|
// nuance to the catalog sync.
|
|
func validateTMDBDiscoverRequest(req importTMDBDiscoverRequest) error {
|
|
switch req.MediaType {
|
|
case "movie", "tv":
|
|
default:
|
|
return fmt.Errorf("media_type must be \"movie\" or \"tv\"")
|
|
}
|
|
if strings.TrimSpace(req.Spec.SortBy) == "" {
|
|
return fmt.Errorf("spec.sort_by is required")
|
|
}
|
|
if req.Limit != nil && *req.Limit <= 0 {
|
|
return fmt.Errorf("limit must be greater than 0")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (h *LibraryCollectionHandler) HandleImportMDBList(w http.ResponseWriter, r *http.Request) {
|
|
var req importMDBListRequest
|
|
if err := decodeJSONOrMultipart(r, &req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
|
return
|
|
}
|
|
if !hasLibrarySelection(req.LibraryID, req.LibraryIDs) || strings.TrimSpace(req.Title) == "" || strings.TrimSpace(req.URL) == "" {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "library_id/library_ids, title, and url are required")
|
|
return
|
|
}
|
|
if req.Limit != nil && *req.Limit <= 0 {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "limit must be greater than 0")
|
|
return
|
|
}
|
|
|
|
collection, err := h.createMDBListCollection(r.Context(), req)
|
|
if err != nil {
|
|
var validationErr requestValidationError
|
|
if errors.As(err, &validationErr) {
|
|
writeError(w, http.StatusBadRequest, "bad_request", validationErr.Error())
|
|
return
|
|
}
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to create collection")
|
|
return
|
|
}
|
|
|
|
// Process admin artwork before sync so maybeGenerateCollage sees the
|
|
// uploaded poster and skips collage generation.
|
|
if err := h.processArtworkInputs(r, collection.ID, req.PosterSourceURL, req.BackdropSourceURL); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to process uploaded images")
|
|
return
|
|
}
|
|
|
|
run, err := h.service.SyncCollection(r.Context(), collection.ID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to import MDBList collection")
|
|
return
|
|
}
|
|
|
|
refreshed, err := h.repo.GetByID(r.Context(), collection.ID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load collection")
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusCreated, importCollectionResponse{
|
|
Collection: h.toLibraryCollectionResponse(r, refreshed),
|
|
SyncRun: run,
|
|
})
|
|
}
|
|
|
|
func (h *LibraryCollectionHandler) HandleImportTMDBCollection(w http.ResponseWriter, r *http.Request) {
|
|
var req importTMDBRequest
|
|
if err := decodeJSONOrMultipart(r, &req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
|
return
|
|
}
|
|
if !hasLibrarySelection(req.LibraryID, req.LibraryIDs) || strings.TrimSpace(req.Title) == "" {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "library_id/library_ids and title are required")
|
|
return
|
|
}
|
|
if _, _, _, err := normalizeTMDBPresetRequest(req.Preset, req.MediaType, req.TimeWindow); err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
|
return
|
|
}
|
|
if req.Limit != nil && *req.Limit <= 0 {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "limit must be greater than 0")
|
|
return
|
|
}
|
|
|
|
collection, err := h.createTMDBCollection(r.Context(), req)
|
|
if err != nil {
|
|
var validationErr requestValidationError
|
|
if errors.As(err, &validationErr) {
|
|
writeError(w, http.StatusBadRequest, "bad_request", validationErr.Error())
|
|
return
|
|
}
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to create collection")
|
|
return
|
|
}
|
|
|
|
// Process admin artwork before sync so maybeGenerateCollage sees the
|
|
// uploaded poster and skips collage generation.
|
|
if err := h.processArtworkInputs(r, collection.ID, req.PosterSourceURL, req.BackdropSourceURL); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to process uploaded images")
|
|
return
|
|
}
|
|
|
|
run, err := h.service.SyncCollection(r.Context(), collection.ID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to sync TMDB collection")
|
|
return
|
|
}
|
|
|
|
refreshed, err := h.repo.GetByID(r.Context(), collection.ID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load collection")
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusCreated, importCollectionResponse{
|
|
Collection: h.toLibraryCollectionResponse(r, refreshed),
|
|
SyncRun: run,
|
|
})
|
|
}
|
|
|
|
func (h *LibraryCollectionHandler) HandleImportTraktCollection(w http.ResponseWriter, r *http.Request) {
|
|
var req importTraktRequest
|
|
if err := decodeJSONOrMultipart(r, &req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
|
return
|
|
}
|
|
if !hasLibrarySelection(req.LibraryID, req.LibraryIDs) || strings.TrimSpace(req.Title) == "" {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "library_id/library_ids and title are required")
|
|
return
|
|
}
|
|
preset, mediaType, profileID, err := normalizeTraktPresetRequest(req.Preset, req.MediaType, req.ProfileID)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
|
return
|
|
}
|
|
if req.Limit != nil && *req.Limit <= 0 {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "limit must be greater than 0")
|
|
return
|
|
}
|
|
|
|
var syncSchedule *string
|
|
if s := strings.TrimSpace(req.SyncSchedule); s != "" {
|
|
if err := catalog.ParseCronExpression(s); err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
|
return
|
|
}
|
|
syncSchedule = &s
|
|
}
|
|
|
|
sourceConfig, err := buildTraktSourceConfig(preset, mediaType, profileID, req.Limit)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to build source config")
|
|
return
|
|
}
|
|
managementMode, managementSource, managementKey, err := normalizeCollectionManagementFields(req.ManagementMode, req.ManagementSource, req.ManagementKey)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
|
return
|
|
}
|
|
|
|
collection, err := h.repo.Create(r.Context(), catalog.CreateLibraryCollectionInput{
|
|
LibraryID: req.LibraryID,
|
|
LibraryIDs: req.LibraryIDs,
|
|
Slug: slugifyCollectionName(req.Title),
|
|
Title: req.Title,
|
|
Description: req.Description,
|
|
CollectionType: "trakt",
|
|
Visibility: "visible",
|
|
Featured: req.Featured,
|
|
PosterURL: req.PosterURL,
|
|
SourceURL: buildTraktSourceURL(preset, mediaType, profileID),
|
|
SourceConfig: sourceConfig,
|
|
ManagementMode: managementMode,
|
|
ManagementSource: managementSource,
|
|
ManagementKey: managementKey,
|
|
SyncSchedule: syncSchedule,
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to create collection")
|
|
return
|
|
}
|
|
|
|
if _, _, _, err := h.storeBundledTemplatePoster(r.Context(), collection.ID, req.PosterURL, false); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to process template poster")
|
|
return
|
|
}
|
|
if err := h.processArtworkInputs(r, collection.ID, req.PosterSourceURL, req.BackdropSourceURL); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to process uploaded images")
|
|
return
|
|
}
|
|
|
|
run, err := h.service.SyncCollection(r.Context(), collection.ID)
|
|
if err != nil {
|
|
slog.Error("failed to sync imported Trakt collection",
|
|
"collection_id", collection.ID,
|
|
"library_id", req.LibraryID,
|
|
"title", req.Title,
|
|
"preset", preset,
|
|
"media_type", mediaType,
|
|
"error", err,
|
|
)
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to sync Trakt collection")
|
|
return
|
|
}
|
|
|
|
refreshed, err := h.repo.GetByID(r.Context(), collection.ID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load collection")
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusCreated, importCollectionResponse{
|
|
Collection: h.toLibraryCollectionResponse(r, refreshed),
|
|
SyncRun: run,
|
|
})
|
|
}
|
|
|
|
func (h *LibraryCollectionHandler) loadOrderedCollectionItems(r *http.Request, collectionID string) ([]itemListResponse, error) {
|
|
collectionItems, err := h.repo.ListItems(r.Context(), collectionID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
contentIDs := make([]string, 0, len(collectionItems))
|
|
for _, item := range collectionItems {
|
|
contentIDs = append(contentIDs, item.MediaItemID)
|
|
}
|
|
|
|
items, err := h.itemRepo.GetByIDs(r.Context(), contentIDs)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
itemByID := make(map[string]*models.MediaItem, len(items))
|
|
for _, item := range items {
|
|
itemByID[item.ContentID] = item
|
|
}
|
|
|
|
resp := make([]itemListResponse, 0, len(collectionItems))
|
|
for _, collectionItem := range collectionItems {
|
|
item, ok := itemByID[collectionItem.MediaItemID]
|
|
if !ok {
|
|
continue
|
|
}
|
|
resp = append(resp, h.toItemListResponse(r, item))
|
|
}
|
|
return resp, nil
|
|
}
|
|
|
|
func (h *LibraryCollectionHandler) loadLiveCollectionItems(r *http.Request, collection *models.LibraryCollection) ([]itemListResponse, error) {
|
|
if h.Executor == nil {
|
|
return nil, fmt.Errorf("query executor is not configured")
|
|
}
|
|
|
|
var def catalog.QueryDefinition
|
|
if len(collection.QueryDefinition) > 0 {
|
|
if err := json.Unmarshal(collection.QueryDefinition, &def); err != nil {
|
|
return nil, smartCollectionQueryError{fmt.Errorf("parsing smart collection query definition: %w", err)}
|
|
}
|
|
}
|
|
def = def.Normalize()
|
|
if err := def.ValidateWithOptions(false, false); err != nil {
|
|
return nil, smartCollectionQueryError{fmt.Errorf("validating smart collection query definition: %w", err)}
|
|
}
|
|
def = catalog.ApplySmartCollectionItemLimit(def)
|
|
|
|
switch {
|
|
case len(collection.LibraryIDs) > 0:
|
|
def.LibraryIDs = intersectCollectionLibraryIDs(def.LibraryIDs, collection.LibraryIDs)
|
|
if len(def.LibraryIDs) == 0 {
|
|
return []itemListResponse{}, nil
|
|
}
|
|
case collection.LibraryID > 0:
|
|
def.LibraryIDs = intersectCollectionLibraryIDs(def.LibraryIDs, []int{collection.LibraryID})
|
|
if len(def.LibraryIDs) == 0 {
|
|
return []itemListResponse{}, nil
|
|
}
|
|
}
|
|
|
|
items, total, err := h.Executor.Preview(r.Context(), def, requestAccessFilter(r), 1)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if total == 0 {
|
|
return []itemListResponse{}, nil
|
|
}
|
|
if total > len(items) {
|
|
items, _, err = h.Executor.Preview(r.Context(), def, requestAccessFilter(r), total)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
resp := make([]itemListResponse, 0, len(items))
|
|
for _, item := range items {
|
|
resp = append(resp, h.toItemListResponse(r, item))
|
|
}
|
|
return resp, nil
|
|
}
|
|
|
|
type smartCollectionQueryError struct {
|
|
err error
|
|
}
|
|
|
|
func (e smartCollectionQueryError) Error() string {
|
|
return e.err.Error()
|
|
}
|
|
|
|
func (e smartCollectionQueryError) Unwrap() error {
|
|
return e.err
|
|
}
|
|
|
|
func intersectCollectionLibraryIDs(existing, required []int) []int {
|
|
if len(required) == 0 {
|
|
return append([]int(nil), existing...)
|
|
}
|
|
if len(existing) == 0 {
|
|
return append([]int(nil), required...)
|
|
}
|
|
|
|
allowed := make(map[int]struct{}, len(required))
|
|
for _, id := range required {
|
|
allowed[id] = struct{}{}
|
|
}
|
|
|
|
result := make([]int, 0, len(existing))
|
|
seen := make(map[int]struct{}, len(existing))
|
|
for _, id := range existing {
|
|
if _, ok := allowed[id]; !ok {
|
|
continue
|
|
}
|
|
if _, ok := seen[id]; ok {
|
|
continue
|
|
}
|
|
seen[id] = struct{}{}
|
|
result = append(result, id)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func (h *LibraryCollectionHandler) toItemListResponse(r *http.Request, item *models.MediaItem) itemListResponse {
|
|
resp := itemListResponse{
|
|
ContentID: item.ContentID,
|
|
Type: item.Type,
|
|
Title: item.Title,
|
|
Year: item.Year,
|
|
Genres: item.Genres,
|
|
ContentRating: item.ContentRating,
|
|
Status: item.Status,
|
|
RatingIMDB: item.RatingIMDB,
|
|
Overview: item.Overview,
|
|
PosterThumbhash: item.PosterThumbhash,
|
|
BackdropThumbhash: item.BackdropThumbhash,
|
|
}
|
|
resp.PosterURL = h.presignURL(r, cardThumbnailPath(item.PosterPath), "card")
|
|
resp.BackdropURL = h.presignURL(r, cardThumbnailPath(item.BackdropPath), "card")
|
|
return resp
|
|
}
|
|
|
|
func (h *LibraryCollectionHandler) presignURL(r *http.Request, path string, variant string) string {
|
|
if h.detailSvc != nil {
|
|
return h.detailSvc.PresignURL(r.Context(), path, variant)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (h *LibraryCollectionHandler) presignGPURL(r *http.Request, path string) string {
|
|
if path == "" {
|
|
return ""
|
|
}
|
|
if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") {
|
|
return path
|
|
}
|
|
if strings.HasPrefix(path, "/") {
|
|
return path
|
|
}
|
|
if h.s3GP == nil {
|
|
return ""
|
|
}
|
|
url, err := h.s3GP.PresignGetURL(r.Context(), h.s3GP.Bucket(), cardThumbnailPath(path), h.presignTTL)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return url
|
|
}
|
|
|
|
func (h *LibraryCollectionHandler) toLibraryCollectionResponse(r *http.Request, collection *models.LibraryCollection) libraryCollectionResponse {
|
|
resp := libraryCollectionResponse{
|
|
ID: collection.ID,
|
|
LibraryID: collection.LibraryID,
|
|
LibraryIDs: append([]int(nil), collection.LibraryIDs...),
|
|
Slug: collection.Slug,
|
|
Title: collection.Title,
|
|
Description: collection.Description,
|
|
CollectionType: collection.CollectionType,
|
|
Visibility: collection.Visibility,
|
|
SortOrder: collection.SortOrder,
|
|
GroupID: collection.GroupID,
|
|
Featured: collection.Featured,
|
|
PosterURL: h.presignGPURL(r, collection.PosterURL),
|
|
BackdropURL: h.presignGPURL(r, collection.BackdropURL),
|
|
PosterThumbhash: collection.PosterThumbhash,
|
|
BackdropThumbhash: collection.BackdropThumbhash,
|
|
SourceURL: collection.SourceURL,
|
|
QueryDefinition: defaultJSON(collection.QueryDefinition),
|
|
SortConfig: defaultJSON(collection.SortConfig),
|
|
SourceConfig: collection.SourceConfig,
|
|
ManagementMode: collection.ManagementMode,
|
|
ManagementSource: collection.ManagementSource,
|
|
ManagementKey: collection.ManagementKey,
|
|
LastSyncStatus: collection.LastSyncStatus,
|
|
LastSyncMessage: collection.LastSyncMessage,
|
|
ItemCount: collection.ItemCount,
|
|
CreatedAt: collection.CreatedAt.Format(time.RFC3339),
|
|
UpdatedAt: collection.UpdatedAt.Format(time.RFC3339),
|
|
}
|
|
if collection.LastSyncAt != nil {
|
|
resp.LastSyncAt = collection.LastSyncAt.Format(time.RFC3339)
|
|
}
|
|
if collection.SyncSchedule != nil {
|
|
resp.SyncSchedule = *collection.SyncSchedule
|
|
}
|
|
if collection.NextSyncAt != nil {
|
|
resp.NextSyncAt = collection.NextSyncAt.Format(time.RFC3339)
|
|
}
|
|
return resp
|
|
}
|
|
|
|
func (h *LibraryCollectionHandler) toLibraryCollectionResponses(r *http.Request, collections []*models.LibraryCollection) []libraryCollectionResponse {
|
|
out := make([]libraryCollectionResponse, 0, len(collections))
|
|
for _, collection := range collections {
|
|
out = append(out, h.toLibraryCollectionResponse(r, collection))
|
|
}
|
|
return out
|
|
}
|
|
|
|
func parsePathLibraryID(w http.ResponseWriter, r *http.Request) (int, bool) {
|
|
libraryID, err := strconv.Atoi(chi.URLParam(r, "id"))
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid library ID")
|
|
return 0, false
|
|
}
|
|
return libraryID, true
|
|
}
|
|
|
|
func requestCanAccessLibrary(r *http.Request, libraryID int) bool {
|
|
scope, ok := access.GetScope(r.Context())
|
|
if !ok || scope.AllowedLibraryIDs == nil {
|
|
return true
|
|
}
|
|
return slices.Contains(scope.AllowedLibraryIDs, libraryID)
|
|
}
|
|
|
|
func slugifyCollectionName(title string) string {
|
|
title = strings.ToLower(strings.TrimSpace(title))
|
|
var builder strings.Builder
|
|
lastHyphen := false
|
|
for _, r := range title {
|
|
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
|
|
builder.WriteRune(r)
|
|
lastHyphen = false
|
|
continue
|
|
}
|
|
if !lastHyphen {
|
|
builder.WriteByte('-')
|
|
lastHyphen = true
|
|
}
|
|
}
|
|
return strings.Trim(builder.String(), "-")
|
|
}
|
|
|
|
func defaultCollectionVisibility(visibility string) string {
|
|
if visibility == "" {
|
|
return "visible"
|
|
}
|
|
return visibility
|
|
}
|
|
|
|
func normalizeCollectionManagementMode(mode string) string {
|
|
switch strings.TrimSpace(strings.ToLower(mode)) {
|
|
case collectionManagementModeSection, collectionManagementModeTemplateBundle:
|
|
return strings.TrimSpace(strings.ToLower(mode))
|
|
default:
|
|
return collectionManagementModeManual
|
|
}
|
|
}
|
|
|
|
func normalizeCollectionManagementFields(mode, source, key string) (string, string, string, error) {
|
|
normalizedMode := normalizeCollectionManagementMode(mode)
|
|
normalizedSource := strings.TrimSpace(source)
|
|
normalizedKey := strings.TrimSpace(key)
|
|
if normalizedMode != collectionManagementModeManual && normalizedKey == "" {
|
|
return "", "", "", fmt.Errorf("management_key is required for managed collections")
|
|
}
|
|
if normalizedMode == collectionManagementModeManual {
|
|
normalizedSource = ""
|
|
normalizedKey = ""
|
|
}
|
|
return normalizedMode, normalizedSource, normalizedKey, nil
|
|
}
|
|
|
|
func normalizeOptionalCollectionManagementMode(mode *string) *string {
|
|
if mode == nil {
|
|
return nil
|
|
}
|
|
normalized := normalizeCollectionManagementMode(*mode)
|
|
return &normalized
|
|
}
|
|
|
|
func defaultCollectionSourceConfig(config json.RawMessage) json.RawMessage {
|
|
if len(config) == 0 {
|
|
return json.RawMessage(`{}`)
|
|
}
|
|
return config
|
|
}
|
|
|
|
func buildMDBListSourceConfig(url string, limit *int) (json.RawMessage, error) {
|
|
payload := struct {
|
|
Mode string `json:"mode"`
|
|
URL string `json:"url"`
|
|
Limit *int `json:"limit,omitempty"`
|
|
}{
|
|
Mode: "mdblist_json",
|
|
URL: url,
|
|
}
|
|
if limit != nil && *limit > 0 {
|
|
payload.Limit = limit
|
|
}
|
|
|
|
raw, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return raw, nil
|
|
}
|
|
|
|
func buildTMDBSourceConfig(preset, mediaType, timeWindow string, limit *int) (json.RawMessage, error) {
|
|
payload := struct {
|
|
Mode string `json:"mode"`
|
|
Preset string `json:"preset"`
|
|
MediaType string `json:"media_type"`
|
|
TimeWindow string `json:"time_window,omitempty"`
|
|
Limit *int `json:"limit,omitempty"`
|
|
}{
|
|
Mode: "tmdb_preset",
|
|
Preset: preset,
|
|
MediaType: mediaType,
|
|
Limit: limit,
|
|
}
|
|
if preset == "trending" {
|
|
payload.TimeWindow = timeWindow
|
|
}
|
|
|
|
raw, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return raw, nil
|
|
}
|
|
|
|
// buildTMDBCollectionSourceConfig emits the `source_config` JSON for a TMDB
|
|
// franchise/saga collection. CollectionID == 0 is permitted and round-trips
|
|
// through omitempty as no key at all — the catalog sync path uses that to
|
|
// detect a placeholder template that an admin still needs to configure.
|
|
func buildTMDBCollectionSourceConfig(collectionID int, limit *int) (json.RawMessage, error) {
|
|
payload := struct {
|
|
Mode string `json:"mode"`
|
|
CollectionID int `json:"collection_id,omitempty"`
|
|
Limit *int `json:"limit,omitempty"`
|
|
}{
|
|
Mode: collectionSourceModeTMDBCollection,
|
|
CollectionID: collectionID,
|
|
Limit: limit,
|
|
}
|
|
raw, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return raw, nil
|
|
}
|
|
|
|
// buildTMDBCollectionSourceURL composes the deterministic source URL string
|
|
// stored alongside the collection. Mirrors buildTMDBSourceURL.
|
|
func buildTMDBCollectionSourceURL(collectionID int) string {
|
|
return fmt.Sprintf("tmdb://collection/%d", collectionID)
|
|
}
|
|
|
|
// tmdbDiscoverConfigBody is the on-disk shape of the `discover` object inside
|
|
// a tmdb_discover collection's source_config. Field names match the
|
|
// templates.TMDBDiscoverSpec JSON contract so the catalog sync can decode the
|
|
// same JSON either way.
|
|
type tmdbDiscoverConfigBody struct {
|
|
WithGenres []int `json:"with_genres,omitempty"`
|
|
WithoutGenres []int `json:"without_genres,omitempty"`
|
|
SortBy string `json:"sort_by"`
|
|
VoteCountGte int `json:"vote_count_gte,omitempty"`
|
|
VoteAverageGte float64 `json:"vote_average_gte,omitempty"`
|
|
ReleaseDateGte string `json:"release_date_gte,omitempty"`
|
|
ReleaseDateLte string `json:"release_date_lte,omitempty"`
|
|
Certifications []string `json:"certifications,omitempty"`
|
|
CertificationLte string `json:"certification_lte,omitempty"`
|
|
WithRuntimeGte int `json:"with_runtime_gte,omitempty"`
|
|
WithRuntimeLte int `json:"with_runtime_lte,omitempty"`
|
|
OriginalLanguage string `json:"original_language,omitempty"`
|
|
}
|
|
|
|
// buildTMDBDiscoverSourceConfig emits the `source_config` JSON for a TMDB
|
|
// discover collection. The on-disk shape is:
|
|
//
|
|
// { "mode":"tmdb_discover", "media_type":"movie",
|
|
// "discover": { "sort_by":"...", ... }, "limit": 50 }
|
|
//
|
|
// The discover sub-object exists so additional discover-only fields don't
|
|
// pollute the top-level libraryCollectionSourceConfig struct used for the
|
|
// existing tmdb_preset / trakt_preset / mdblist_json modes.
|
|
func buildTMDBDiscoverSourceConfig(mediaType string, spec importTMDBDiscoverSpecBody, limit *int) (json.RawMessage, error) {
|
|
payload := struct {
|
|
Mode string `json:"mode"`
|
|
MediaType string `json:"media_type"`
|
|
Limit *int `json:"limit,omitempty"`
|
|
Discover tmdbDiscoverConfigBody `json:"discover"`
|
|
}{
|
|
Mode: collectionSourceModeTMDBDiscover,
|
|
MediaType: mediaType,
|
|
Limit: limit,
|
|
Discover: tmdbDiscoverConfigBody{
|
|
WithGenres: spec.WithGenres,
|
|
WithoutGenres: spec.WithoutGenres,
|
|
SortBy: spec.SortBy,
|
|
VoteCountGte: spec.VoteCountGte,
|
|
VoteAverageGte: spec.VoteAverageGte,
|
|
ReleaseDateGte: spec.ReleaseDateGte,
|
|
ReleaseDateLte: spec.ReleaseDateLte,
|
|
Certifications: spec.Certifications,
|
|
CertificationLte: spec.CertificationLte,
|
|
WithRuntimeGte: spec.WithRuntimeGte,
|
|
WithRuntimeLte: spec.WithRuntimeLte,
|
|
OriginalLanguage: spec.OriginalLanguage,
|
|
},
|
|
}
|
|
raw, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return raw, nil
|
|
}
|
|
|
|
// buildTMDBDiscoverSourceURL composes a deterministic source URL string for
|
|
// a TMDB discover collection. The URL is purely informational — sync uses
|
|
// source_config.
|
|
func buildTMDBDiscoverSourceURL(mediaType, sortBy string) string {
|
|
return fmt.Sprintf("tmdb://discover/%s?sort_by=%s", mediaType, sortBy)
|
|
}
|
|
|
|
func buildTraktSourceConfig(preset, mediaType, profileID string, limit *int) (json.RawMessage, error) {
|
|
payload := struct {
|
|
Mode string `json:"mode"`
|
|
Provider string `json:"provider"`
|
|
Preset string `json:"preset"`
|
|
MediaType string `json:"media_type"`
|
|
ProfileID string `json:"profile_id,omitempty"`
|
|
Limit *int `json:"limit,omitempty"`
|
|
}{
|
|
Mode: "trakt_preset",
|
|
Provider: "trakt",
|
|
Preset: preset,
|
|
MediaType: mediaType,
|
|
ProfileID: profileID,
|
|
Limit: limit,
|
|
}
|
|
raw, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return raw, nil
|
|
}
|
|
|
|
func normalizeTMDBPresetRequest(preset, mediaType, timeWindow string) (string, string, string, error) {
|
|
switch preset {
|
|
case "trending":
|
|
switch mediaType {
|
|
case "movie", "tv", "all":
|
|
default:
|
|
return "", "", "", fmt.Errorf("media_type must be \"movie\", \"tv\", or \"all\"")
|
|
}
|
|
switch timeWindow {
|
|
case "day", "week":
|
|
default:
|
|
return "", "", "", fmt.Errorf("time_window must be \"day\" or \"week\"")
|
|
}
|
|
return preset, mediaType, timeWindow, nil
|
|
case "popular", "top_rated":
|
|
switch mediaType {
|
|
case "movie", "tv":
|
|
return preset, mediaType, "", nil
|
|
default:
|
|
return "", "", "", fmt.Errorf("media_type must be \"movie\" or \"tv\" for preset %q", preset)
|
|
}
|
|
case "now_playing", "upcoming":
|
|
if mediaType != "movie" {
|
|
return "", "", "", fmt.Errorf("media_type must be \"movie\" for preset %q", preset)
|
|
}
|
|
return preset, mediaType, "", nil
|
|
case "airing_today", "on_the_air":
|
|
if mediaType != "tv" {
|
|
return "", "", "", fmt.Errorf("media_type must be \"tv\" for preset %q", preset)
|
|
}
|
|
return preset, mediaType, "", nil
|
|
default:
|
|
return "", "", "", fmt.Errorf("preset must be one of \"trending\", \"popular\", \"top_rated\", \"now_playing\", \"upcoming\", \"airing_today\", or \"on_the_air\"")
|
|
}
|
|
}
|
|
|
|
func normalizeTraktPresetRequest(preset, mediaType, profileID string) (string, string, string, error) {
|
|
preset = strings.TrimSpace(strings.ToLower(preset))
|
|
mediaType = strings.TrimSpace(strings.ToLower(mediaType))
|
|
profileID = strings.TrimSpace(profileID)
|
|
|
|
switch preset {
|
|
case "trending", "popular":
|
|
case "recommended":
|
|
if profileID == "" {
|
|
return "", "", "", fmt.Errorf("profile_id is required for Trakt recommended collections")
|
|
}
|
|
default:
|
|
return "", "", "", fmt.Errorf("preset must be one of \"trending\", \"popular\", or \"recommended\"")
|
|
}
|
|
switch mediaType {
|
|
case "movie", "tv":
|
|
default:
|
|
return "", "", "", fmt.Errorf("media_type must be \"movie\" or \"tv\"")
|
|
}
|
|
if preset != "recommended" {
|
|
profileID = ""
|
|
}
|
|
return preset, mediaType, profileID, nil
|
|
}
|
|
|
|
func buildTMDBSourceURL(preset, mediaType, timeWindow string) string {
|
|
if preset == "trending" {
|
|
return fmt.Sprintf("tmdb://%s/%s/%s", preset, mediaType, timeWindow)
|
|
}
|
|
return fmt.Sprintf("tmdb://%s/%s", preset, mediaType)
|
|
}
|
|
|
|
func buildTraktSourceURL(preset, mediaType, profileID string) string {
|
|
if preset == "recommended" {
|
|
return fmt.Sprintf("trakt://%s/%s/%s", preset, mediaType, profileID)
|
|
}
|
|
return fmt.Sprintf("trakt://%s/%s", preset, mediaType)
|
|
}
|
|
|
|
func (h *LibraryCollectionHandler) processCollectionImage(
|
|
ctx context.Context,
|
|
collectionID string,
|
|
imageType string,
|
|
fileData []byte,
|
|
) (s3Path string, thumbhashStr string, err error) {
|
|
return uploadCollectionImageVariants(ctx, h.s3GP, adminCollectionImagePrefix, collectionID, imageType, fileData)
|
|
}
|
|
|
|
func (h *LibraryCollectionHandler) deleteCollectionImages(ctx context.Context, collectionID, imageType string) error {
|
|
return removeCollectionImageVariants(ctx, h.s3GP, adminCollectionImagePrefix, collectionID, imageType)
|
|
}
|
|
|
|
func (h *LibraryCollectionHandler) processArtworkInputs(r *http.Request, collectionID, posterSourceURL, backdropSourceURL string) error {
|
|
sourceByType := map[string]string{
|
|
"poster": strings.TrimSpace(posterSourceURL),
|
|
"backdrop": strings.TrimSpace(backdropSourceURL),
|
|
}
|
|
isMultipart := strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/")
|
|
|
|
for _, imageType := range []string{"poster", "backdrop"} {
|
|
var (
|
|
fileData []byte
|
|
err error
|
|
)
|
|
if isMultipart {
|
|
fileData, err = readCollectionImageMultipart(r, imageType)
|
|
} else {
|
|
err = http.ErrMissingFile
|
|
}
|
|
|
|
switch {
|
|
case err == nil:
|
|
case err == http.ErrMissingFile:
|
|
if sourceByType[imageType] == "" {
|
|
continue
|
|
}
|
|
fileData, err = downloadCollectionImageURL(r.Context(), h.httpClient, sourceByType[imageType])
|
|
if err != nil {
|
|
return fmt.Errorf("%s source: %w", imageType, err)
|
|
}
|
|
default:
|
|
return fmt.Errorf("%s: %w", imageType, err)
|
|
}
|
|
|
|
if err := h.deleteCollectionImages(r.Context(), collectionID, imageType); err != nil {
|
|
return fmt.Errorf("deleting %s images: %w", imageType, err)
|
|
}
|
|
|
|
s3Path, thumbhash, err := h.processCollectionImage(r.Context(), collectionID, imageType, fileData)
|
|
if err != nil {
|
|
return fmt.Errorf("%s: %w", imageType, err)
|
|
}
|
|
|
|
posterURL, backdropURL := (*string)(nil), (*string)(nil)
|
|
posterThumbhash, backdropThumbhash := (*string)(nil), (*string)(nil)
|
|
if imageType == "poster" {
|
|
posterURL = &s3Path
|
|
posterThumbhash = &thumbhash
|
|
} else {
|
|
backdropURL = &s3Path
|
|
backdropThumbhash = &thumbhash
|
|
}
|
|
|
|
update := catalog.UpdateLibraryCollectionInput{
|
|
ID: collectionID,
|
|
PosterURL: posterURL,
|
|
BackdropURL: backdropURL,
|
|
PosterThumbhash: posterThumbhash,
|
|
BackdropThumbhash: backdropThumbhash,
|
|
}
|
|
if imageType == "poster" {
|
|
adminUpload := false
|
|
fromTemplate := false
|
|
update.PosterAutoGenerated = &adminUpload
|
|
update.PosterFromTemplate = &fromTemplate
|
|
}
|
|
if err := h.repo.Update(r.Context(), update); err != nil {
|
|
return fmt.Errorf("updating %s: %w", imageType, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func decodeJSONOrMultipart(r *http.Request, dest interface{}) error {
|
|
ct := r.Header.Get("Content-Type")
|
|
if strings.HasPrefix(ct, "multipart/") {
|
|
if err := r.ParseMultipartForm(20 << 20); err != nil {
|
|
return fmt.Errorf("parsing multipart form: %w", err)
|
|
}
|
|
dataField := r.FormValue("data")
|
|
if dataField == "" {
|
|
return fmt.Errorf("missing 'data' field in multipart form")
|
|
}
|
|
return json.Unmarshal([]byte(dataField), dest)
|
|
}
|
|
return json.NewDecoder(r.Body).Decode(dest)
|
|
}
|
|
|
|
func pointerStringValue(value *string) string {
|
|
if value == nil {
|
|
return ""
|
|
}
|
|
return *value
|
|
}
|