* fix(collections): sync matches across all bound libraries Collection sync resolved item membership against only the legacy library_collections.library_id, so a collection bound to multiple libraries (N:N library_collection_libraries) had its items collapsed to a single library on every sync. Switch the MDBList/TMDB/Trakt sync matchers to the existing LibraryItemRepository.GetItemsInFolders over collection.LibraryIDs. GetByID already populates LibraryIDs from the N:N table (falling back to [LibraryID]), so single-library collections are unaffected. No schema change; listing and the catalog open path were already N:N-aware. Unblocks merging duplicated per-library collections into single multi-library collections. * fix(collections): dedupe matched items by media_item_id before ReplaceItems A collection sync resolves source entries (external IDs) to library content_ids and appends one item per matched entry without deduping by media_item_id. When two different source entries resolve to the same library item, the same content_id is appended twice; ReplaceItems then hits the library_collection_items composite PK (collection_id, media_item_id) with a duplicate-key violation, failing the whole sync with an HTTP 500 and zero items. The multi-library union added in the previous commit (GetItemsInFolders over collection.LibraryIDs) widens the matched set and so raises the collision probability, making this latent bug more likely to fire. Dedupe at the single choke point in ReplaceItems (first occurrence wins, preserving rank order) so all five sync paths are covered at once, and renumber position over the surviving rows so it stays dense. Also log the underlying error in HandleSyncAdminCollection, which previously mapped every non-sentinel sync error to a generic 500 with no log line, making this failure invisible on the server.
3701 lines
127 KiB
Go
3701 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
|
|
case "audiobook", "audiobooks":
|
|
// Audiobook libraries only accept audiobook-targeted templates.
|
|
// Today the catalog has none (built-ins are TMDB/Trakt/MDBList
|
|
// movie/TV imports), so this evaluates to an empty gallery —
|
|
// the correct UX. Previously this fell through to default:true
|
|
// and offered admins broken movie/TV templates.
|
|
return tmpl.MediaKind == templates.MediaAudiobook || 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())
|
|
profileID := apimw.GetProfileID(r.Context())
|
|
collections, err := usercollections.ListServerVisibleByLibrary(r.Context(), h.UserCollectionPool, userID, profileID, 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())
|
|
profileID := apimw.GetProfileID(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, profileID, 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
|
|
}
|
|
slog.Error("collection sync failed", "collection_id", collectionID, "error", err)
|
|
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 = catalog.IntersectCollectionLibraryIDs(def.LibraryIDs, collection.LibraryIDs)
|
|
if len(def.LibraryIDs) == 0 {
|
|
return []itemListResponse{}, nil
|
|
}
|
|
case collection.LibraryID > 0:
|
|
def.LibraryIDs = catalog.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
|
|
}
|
|
|
|
// intersectCollectionLibraryIDs moved to catalog.IntersectCollectionLibraryIDs
|
|
// so the web API and the Jellyfin-compat resolver share one implementation.
|
|
|
|
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
|
|
}
|