Files
silo-server/internal/api/handlers/library_collections.go
T
CoffeeKnyteandGitHub 6414061022 fix(collections): correct multi-library sync — match all bound libraries and dedupe items (#184)
* 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.
2026-06-26 12:28:14 -04:00

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: &notAutoGenerated,
}
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: &notFromTemplate,
}); 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 = &notAutoGen
input.PosterFromTemplate = &notFromTemplate
} 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: &notAutoGenerated,
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, &sections.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
}