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

3716 lines
127 KiB
Go

package handlers
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"net/http"
"runtime/debug"
"slices"
"sort"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"golang.org/x/sync/errgroup"
"log/slog"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/Silo-Server/silo-server/internal/access"
"github.com/Silo-Server/silo-server/internal/adminjob"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/collage"
"github.com/Silo-Server/silo-server/internal/collections/templates"
evt "github.com/Silo-Server/silo-server/internal/events"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/s3client"
"github.com/Silo-Server/silo-server/internal/sections"
"github.com/Silo-Server/silo-server/internal/usercollections"
)
type LibraryCollectionHandler struct {
repo *catalog.LibraryCollectionRepository
service *catalog.LibraryCollectionService
itemRepo *catalog.ItemRepository
Executor *catalog.QueryExecutor
detailSvc *catalog.DetailService
presignTTL time.Duration
httpClient *http.Client
s3GP *s3client.Client
FrontendFS fs.FS
SectionRepo *sections.Repository
UserCollectionPool *pgxpool.Pool
GroupRepo *catalog.LibraryCollectionGroupRepository
FolderRepo *catalog.FolderRepository
TemplateRegistry *templates.Registry
SmartCountRefresher *catalog.SmartCountRefresher
JobRepo *adminjob.Repository
EventsHub *evt.Hub
}
var errLibraryCollectionInUse = errors.New("collection is used by one or more sections")
const (
templateBundleSyncConcurrency = 4
templateBundleInlineSyncLimit = 20
templateBundleCollageConcurrency = 2
templateBundleCollageTimeout = 5 * time.Minute
templateBundleAsyncSyncTimeout = 30 * time.Minute
collectionManagementModeManual = "manual"
collectionManagementModeSection = "section"
collectionManagementModeTemplateBundle = "template_bundle"
// collectionSourceModeTMDBCollection is the `source_config.mode` value used
// for TMDB-franchise collections (curated `/collection/{id}` results).
collectionSourceModeTMDBCollection = "tmdb_collection"
// collectionSourceModeTMDBDiscover is the `source_config.mode` value used
// for TMDB discover collections (genre matrices, decade slices, etc.).
collectionSourceModeTMDBDiscover = "tmdb_discover"
)
func NewLibraryCollectionHandler(
repo *catalog.LibraryCollectionRepository,
service *catalog.LibraryCollectionService,
itemRepo *catalog.ItemRepository,
presignTTL time.Duration,
httpClient *http.Client,
s3GP *s3client.Client,
) *LibraryCollectionHandler {
if httpClient == nil {
httpClient = http.DefaultClient
}
return &LibraryCollectionHandler{
repo: repo,
service: service,
itemRepo: itemRepo,
presignTTL: presignTTL,
httpClient: httpClient,
s3GP: s3GP,
TemplateRegistry: templates.Default,
}
}
// SetDetailService configures the detail service for image URL resolution.
func (h *LibraryCollectionHandler) SetDetailService(svc *catalog.DetailService) {
h.detailSvc = svc
}
// SetupCollage enables automatic poster collage generation for collections.
// Must be called after SetDetailService.
func (h *LibraryCollectionHandler) SetupCollage() {
if h.s3GP == nil || h.detailSvc == nil {
slog.Info("collage: auto-generation disabled", "s3_configured", h.s3GP != nil, "detail_svc_configured", h.detailSvc != nil)
return
}
h.service.CollageGen = h
slog.Info("collage: auto-generation enabled")
}
func (h *LibraryCollectionHandler) storeBundledTemplatePoster(
ctx context.Context,
collectionID, posterPath string,
fromTemplate bool,
) (bool, string, string, error) {
storedPath, thumbhash, stored, err := storeBundledCollectionPosterIfS3Configured(
ctx,
h.s3GP,
h.FrontendFS,
collectionID,
adminCollectionImagePrefix,
posterPath,
)
if err != nil || !stored {
if err != nil {
slog.Warn("failed to store bundled template poster",
"collection_id", collectionID,
"poster_path", posterPath,
"error", err,
)
}
return false, storedPath, thumbhash, nil
}
notAutoGenerated := false
input := catalog.UpdateLibraryCollectionInput{
ID: collectionID,
PosterURL: &storedPath,
PosterThumbhash: &thumbhash,
PosterAutoGenerated: &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
default:
return true
}
}
type previewLibraryCollectionResponse struct {
Items []itemListResponse `json:"items"`
Total int `json:"total"`
}
type serverUserCollectionsListResponse struct {
Collections []usercollections.ServerVisibleCollection `json:"collections"`
}
type libraryTabCollection struct {
ID string `json:"id"`
Title string `json:"title"`
PosterURL string `json:"poster_url"`
PosterThumbhash string `json:"poster_thumbhash,omitempty"`
ItemCount int `json:"item_count"`
Featured bool `json:"featured,omitempty"`
CreatorProfileID *string `json:"creator_profile_id,omitempty"`
}
type libraryTabGroup struct {
ID string `json:"id"`
Name string `json:"name"`
Kind models.LibraryCollectionGroupKind `json:"kind"`
SortMode models.GroupSortMode `json:"sort_mode"`
SortOrder int `json:"sort_order"`
Collections []libraryTabCollection `json:"collections"`
}
type libraryTabUngrouped struct {
SortOrder int `json:"sort_order"`
Collections []libraryTabCollection `json:"collections"`
}
type libraryTabResponse struct {
LibraryID int `json:"library_id"`
Collections []libraryCollectionResponse `json:"collections,omitempty"`
Groups []libraryTabGroup `json:"groups"`
Ungrouped *libraryTabUngrouped `json:"ungrouped,omitempty"`
}
// HandleListLibraryUserCollections returns the viewer's own personal
// collections that they've opted into their library Collections tab and whose
// library scope matches the requested library. Personal collections are
// private to their owner; this endpoint never reveals other users' rows.
func (h *LibraryCollectionHandler) HandleListLibraryUserCollections(w http.ResponseWriter, r *http.Request) {
libraryID, ok := parsePathLibraryID(w, r)
if !ok {
return
}
if !requestCanAccessLibrary(r, libraryID) {
writeError(w, http.StatusNotFound, "not_found", "Library not found")
return
}
if h.UserCollectionPool == nil {
writeJSON(w, http.StatusOK, serverUserCollectionsListResponse{Collections: []usercollections.ServerVisibleCollection{}})
return
}
userID := apimw.GetUserID(r.Context())
collections, err := usercollections.ListServerVisibleByLibrary(r.Context(), h.UserCollectionPool, userID, libraryID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load user collections")
return
}
if collections == nil {
collections = []usercollections.ServerVisibleCollection{}
}
for i := range collections {
collections[i].PosterURL = h.presignGPURL(r, collections[i].PosterPath)
}
writeJSON(w, http.StatusOK, serverUserCollectionsListResponse{Collections: collections})
}
func (h *LibraryCollectionHandler) HandleListLibraryCollections(w http.ResponseWriter, r *http.Request) {
libraryID, ok := parsePathLibraryID(w, r)
if !ok {
return
}
if !requestCanAccessLibrary(r, libraryID) {
writeError(w, http.StatusNotFound, "not_found", "Library not found")
return
}
adminCollections, err := h.repo.ListByLibrary(r.Context(), libraryID, catalog.ListLibraryCollectionsOptions{})
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load collections")
return
}
if h.GroupRepo == nil {
writeJSON(w, http.StatusOK, libraryCollectionsListResponse{
Collections: h.toLibraryCollectionResponses(r, adminCollections),
})
return
}
userID := apimw.GetUserID(r.Context())
groups, err := h.GroupRepo.ListByLibrary(r.Context(), libraryID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load groups")
return
}
adminCollectionsByGroup := groupLibraryTabCollections(adminCollections)
resp := libraryTabResponse{
LibraryID: libraryID,
Collections: h.toLibraryCollectionResponses(r, adminCollections),
Groups: []libraryTabGroup{},
}
var userCollections []usercollections.ServerVisibleCollection
userCollectionsLoaded := false
for _, g := range groups {
var colls []libraryTabCollection
switch g.Kind {
case models.GroupKindUserCollections:
if h.UserCollectionPool == nil || userID == 0 {
continue
}
if !userCollectionsLoaded {
loadedUserCollections, loadErr := usercollections.ListServerVisibleByLibrary(r.Context(), h.UserCollectionPool, userID, libraryID)
if loadErr != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load user collections")
return
}
userCollections = loadedUserCollections
userCollectionsLoaded = true
}
sorted := applyUserCollectionSort(userCollections, g.DefaultSortMode)
for i := range sorted {
posterURL := h.presignGPURL(r, sorted[i].PosterPath)
creatorProfileID := sorted[i].CreatorProfileID
colls = append(colls, libraryTabCollection{
ID: sorted[i].ID,
Title: sorted[i].Name,
PosterURL: posterURL,
PosterThumbhash: sorted[i].PosterThumbhash,
ItemCount: sorted[i].ItemCount,
CreatorProfileID: &creatorProfileID,
})
}
default:
collections := adminCollectionsByGroup[g.ID]
collections = applyCollectionSort(collections, g.DefaultSortMode)
for _, c := range collections {
colls = append(colls, libraryTabCollection{
ID: c.ID,
Title: c.Title,
PosterURL: h.presignGPURL(r, c.PosterURL),
PosterThumbhash: c.PosterThumbhash,
ItemCount: c.ItemCount,
Featured: c.Featured,
})
}
}
if len(colls) == 0 {
continue
}
resp.Groups = append(resp.Groups, libraryTabGroup{
ID: g.ID,
Name: g.Name,
Kind: g.Kind,
SortMode: g.DefaultSortMode,
SortOrder: g.SortOrder,
Collections: colls,
})
}
ungrouped := adminCollectionsByGroup[groupKeyPtr(nil)]
if len(ungrouped) > 0 {
uColls := make([]libraryTabCollection, 0, len(ungrouped))
for _, c := range ungrouped {
uColls = append(uColls, libraryTabCollection{
ID: c.ID,
Title: c.Title,
PosterURL: h.presignGPURL(r, c.PosterURL),
PosterThumbhash: c.PosterThumbhash,
ItemCount: c.ItemCount,
Featured: c.Featured,
})
}
sortOrder := 9999
if h.GroupRepo != nil {
if sortOrder, err = h.GroupRepo.GetUngroupedSortOrder(r.Context(), libraryID); err != nil {
sortOrder = 9999
}
}
resp.Ungrouped = &libraryTabUngrouped{SortOrder: sortOrder, Collections: uColls}
}
writeJSON(w, http.StatusOK, resp)
}
func toLibraryCollectionGroupResponses(groups []models.LibraryCollectionGroup) []libraryCollectionGroupResponse {
out := make([]libraryCollectionGroupResponse, 0, len(groups))
for _, g := range groups {
out = append(out, toLibraryCollectionGroupResponse(g))
}
return out
}
func groupLibraryTabCollections(collections []*models.LibraryCollection) map[string][]*models.LibraryCollection {
grouped := make(map[string][]*models.LibraryCollection)
for _, collection := range collections {
key := groupKeyPtr(collection.GroupID)
grouped[key] = append(grouped[key], collection)
}
return grouped
}
func groupKeyPtr(id *string) string {
if id == nil {
return ""
}
return *id
}
func toLibraryCollectionGroupResponse(g models.LibraryCollectionGroup) libraryCollectionGroupResponse {
return libraryCollectionGroupResponse{
ID: g.ID,
LibraryID: g.LibraryID,
Name: g.Name,
Slug: g.Slug,
Kind: string(g.Kind),
DefaultSortMode: string(g.DefaultSortMode),
SortOrder: g.SortOrder,
}
}
func applyCollectionSort(collections []*models.LibraryCollection, mode models.GroupSortMode) []*models.LibraryCollection {
out := append([]*models.LibraryCollection(nil), collections...)
switch mode {
case models.GroupSortNameAsc:
sort.Slice(out, func(i, j int) bool { return out[i].Title < out[j].Title })
case models.GroupSortNameDesc:
sort.Slice(out, func(i, j int) bool { return out[i].Title > out[j].Title })
case models.GroupSortRecent:
sort.Slice(out, func(i, j int) bool { return out[i].UpdatedAt.After(out[j].UpdatedAt) })
case models.GroupSortMostItems:
sort.Slice(out, func(i, j int) bool { return out[i].ItemCount > out[j].ItemCount })
}
return out
}
func applyUserCollectionSort(collections []usercollections.ServerVisibleCollection, mode models.GroupSortMode) []usercollections.ServerVisibleCollection {
out := append([]usercollections.ServerVisibleCollection(nil), collections...)
switch mode {
case models.GroupSortNameAsc:
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
case models.GroupSortNameDesc:
sort.Slice(out, func(i, j int) bool { return out[i].Name > out[j].Name })
case models.GroupSortRecent:
sort.Slice(out, func(i, j int) bool { return out[i].UpdatedAt > out[j].UpdatedAt })
case models.GroupSortMostItems:
sort.Slice(out, func(i, j int) bool { return out[i].ItemCount > out[j].ItemCount })
}
return out
}
func (h *LibraryCollectionHandler) refreshSmartCountAsync(collectionID string) {
if h.SmartCountRefresher == nil || collectionID == "" {
return
}
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
defer cancel()
if err := h.SmartCountRefresher.RefreshOne(ctx, collectionID); err != nil {
slog.Warn("smart-count refresh failed", "collection_id", collectionID, "error", err)
}
}()
}
func (h *LibraryCollectionHandler) HandleGetLibraryCollectionItems(w http.ResponseWriter, r *http.Request) {
libraryID, ok := parsePathLibraryID(w, r)
if !ok {
return
}
if !requestCanAccessLibrary(r, libraryID) {
writeError(w, http.StatusNotFound, "not_found", "Library not found")
return
}
collectionID := chi.URLParam(r, "collection_id")
collection, err := h.repo.GetByID(r.Context(), collectionID)
if err != nil || collection.LibraryID != libraryID || collection.Visibility != "visible" {
writeError(w, http.StatusNotFound, "not_found", "Collection not found")
return
}
var items []itemListResponse
if catalog.IsLiveQueryType(collection.CollectionType) {
items, err = h.loadLiveCollectionItems(r, collection)
} else {
items, err = h.loadOrderedCollectionItems(r, collectionID)
}
if err != nil {
var queryErr smartCollectionQueryError
if errors.As(err, &queryErr) {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load collection items")
return
}
writeJSON(w, http.StatusOK, browseResponse{Items: items, Total: len(items), HasMore: false})
}
func (h *LibraryCollectionHandler) HandleListAdminCollections(w http.ResponseWriter, r *http.Request) {
var libraryID *int
if raw := r.URL.Query().Get("library_id"); raw != "" {
parsed, err := strconv.Atoi(raw)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid library_id")
return
}
libraryID = &parsed
}
collectionsCh := make(chan []*models.LibraryCollection, 1)
eg, egCtx := errgroup.WithContext(r.Context())
eg.Go(func() error {
collections, err := h.repo.ListAll(egCtx, libraryID, catalog.ListLibraryCollectionsOptions{IncludeHidden: true})
if err != nil {
return err
}
collectionsCh <- collections
return nil
})
var groupsCh chan []models.LibraryCollectionGroup
if libraryID != nil && h.GroupRepo != nil {
scopedID := *libraryID
groupsCh = make(chan []models.LibraryCollectionGroup, 1)
eg.Go(func() error {
groups, err := h.GroupRepo.ListByLibrary(egCtx, scopedID)
if err != nil {
return err
}
groupsCh <- groups
return nil
})
}
if err := eg.Wait(); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load collections")
return
}
collections := <-collectionsCh
var groups []models.LibraryCollectionGroup
if groupsCh != nil {
groups = <-groupsCh
}
resp := libraryCollectionsListResponse{
Collections: make([]libraryCollectionResponse, 0, len(collections)),
}
for _, collection := range collections {
resp.Collections = append(resp.Collections, h.toLibraryCollectionResponse(r, collection))
}
if libraryID != nil {
resp.Groups = toLibraryCollectionGroupResponses(groups)
}
writeJSON(w, http.StatusOK, resp)
}
func (h *LibraryCollectionHandler) HandleCreateAdminCollection(w http.ResponseWriter, r *http.Request) {
var req createLibraryCollectionRequest
if err := decodeJSONOrMultipart(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
if !hasLibrarySelection(req.LibraryID, req.LibraryIDs) || strings.TrimSpace(req.Title) == "" {
writeError(w, http.StatusBadRequest, "bad_request", "library_id/library_ids and title are required")
return
}
if req.Slug == "" {
req.Slug = slugifyCollectionName(req.Title)
}
if req.CollectionType == "" {
req.CollectionType = "manual"
}
queryDefinition := defaultJSON(req.QueryDefinition)
if req.CollectionType == "smart" {
var err error
queryDefinition, err = normalizeSmartCollectionQueryDefinitionJSON(queryDefinition, false, false)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid query_definition")
return
}
} else if len(req.QueryDefinition) > 0 {
var err error
queryDefinition, err = normalizeQueryDefinitionJSON(queryDefinition, false, false)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid query_definition")
return
}
}
var syncSchedule *string
if s := strings.TrimSpace(req.SyncSchedule); s != "" {
if err := catalog.ParseCronExpression(s); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
syncSchedule = &s
}
managementMode, managementSource, managementKey, err := normalizeCollectionManagementFields(req.ManagementMode, req.ManagementSource, req.ManagementKey)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
collection, err := h.repo.Create(r.Context(), catalog.CreateLibraryCollectionInput{
LibraryID: req.LibraryID,
LibraryIDs: req.LibraryIDs,
Slug: req.Slug,
Title: req.Title,
Description: req.Description,
CollectionType: req.CollectionType,
Visibility: defaultCollectionVisibility(req.Visibility),
SortOrder: req.SortOrder,
GroupID: req.GroupID,
Featured: req.Featured,
PosterURL: req.PosterURL,
BackdropURL: req.BackdropURL,
SourceURL: req.SourceURL,
QueryDefinition: queryDefinition,
SortConfig: defaultJSON(req.SortConfig),
SourceConfig: defaultCollectionSourceConfig(req.SourceConfig),
ManagementMode: managementMode,
ManagementSource: managementSource,
ManagementKey: managementKey,
SyncSchedule: syncSchedule,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to create collection")
return
}
posterStored, _, _, err := h.storeBundledTemplatePoster(r.Context(), collection.ID, req.PosterURL, false)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to process template poster")
return
}
if err := h.processArtworkInputs(r, collection.ID, req.PosterSourceURL, req.BackdropSourceURL); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to process uploaded images")
return
}
if posterStored || r.MultipartForm != nil || strings.TrimSpace(req.PosterSourceURL) != "" || strings.TrimSpace(req.BackdropSourceURL) != "" {
collection, err = h.repo.GetByID(r.Context(), collection.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load collection")
return
}
}
h.refreshSmartCountAsync(collection.ID)
writeJSON(w, http.StatusCreated, h.toLibraryCollectionResponse(r, collection))
}
func (h *LibraryCollectionHandler) HandleUpdateAdminCollection(w http.ResponseWriter, r *http.Request) {
collectionID := chi.URLParam(r, "id")
if collectionID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Collection ID is required")
return
}
var req updateLibraryCollectionRequest
if err := decodeJSONOrMultipart(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
queryDefinition := req.QueryDefinition
if len(req.QueryDefinition) > 0 {
var err error
if req.CollectionType == nil || *req.CollectionType == "smart" {
queryDefinition, err = normalizeSmartCollectionQueryDefinitionJSON(req.QueryDefinition, false, false)
} else {
queryDefinition, err = normalizeQueryDefinitionJSON(req.QueryDefinition, false, false)
}
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid query_definition")
return
}
}
// Validate sync_schedule if provided.
if req.SyncSchedule != nil && *req.SyncSchedule != "" {
if err := catalog.ParseCronExpression(*req.SyncSchedule); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
}
var setGroupID **string
if req.GroupID.Set() {
groupID := req.GroupID.Value()
if groupID != nil && strings.TrimSpace(*groupID) == "" {
groupID = nil
}
setGroupID = &groupID
}
if err := h.repo.Update(r.Context(), catalog.UpdateLibraryCollectionInput{
ID: collectionID,
LibraryIDs: req.LibraryIDs,
Slug: req.Slug,
Title: req.Title,
Description: req.Description,
CollectionType: req.CollectionType,
Visibility: req.Visibility,
SortOrder: req.SortOrder,
SetGroupID: setGroupID,
Featured: req.Featured,
PosterURL: req.PosterURL,
BackdropURL: req.BackdropURL,
SourceURL: req.SourceURL,
QueryDefinition: queryDefinition,
SortConfig: req.SortConfig,
SourceConfig: req.SourceConfig,
ManagementMode: normalizeOptionalCollectionManagementMode(req.ManagementMode),
ManagementSource: req.ManagementSource,
ManagementKey: req.ManagementKey,
SyncSchedule: req.SyncSchedule,
}); err != nil {
if err == catalog.ErrLibraryCollectionNotFound {
writeError(w, http.StatusNotFound, "not_found", "Collection not found")
return
}
if errors.Is(err, catalog.ErrLibraryCollectionGroupNotFound) {
writeError(w, http.StatusNotFound, "not_found", "Collection group not found")
return
}
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update collection")
return
}
if err := h.processArtworkInputs(r, collectionID, pointerStringValue(req.PosterSourceURL), pointerStringValue(req.BackdropSourceURL)); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to process uploaded images")
return
}
updated, err := h.repo.GetByID(r.Context(), collectionID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load collection")
return
}
if len(queryDefinition) > 0 || req.CollectionType != nil {
h.refreshSmartCountAsync(collectionID)
}
writeJSON(w, http.StatusOK, h.toLibraryCollectionResponse(r, updated))
}
func (h *LibraryCollectionHandler) HandlePreviewAdminCollection(w http.ResponseWriter, r *http.Request) {
if h.Executor == nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Preview is not configured")
return
}
var req previewCollectionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
var def catalog.QueryDefinition
if len(req.QueryDefinition) > 0 {
normalized, err := normalizeQueryDefinitionJSON(req.QueryDefinition, false, false)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid query_definition")
return
}
if err := json.Unmarshal(normalized, &def); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid query_definition")
return
}
}
items, total, err := h.Executor.Preview(r.Context(), def, catalog.AccessFilter{}, req.Limit)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
resp := previewLibraryCollectionResponse{Items: make([]itemListResponse, 0, len(items)), Total: total}
for _, item := range items {
resp.Items = append(resp.Items, h.toItemListResponse(r, item))
}
writeJSON(w, http.StatusOK, resp)
}
func (h *LibraryCollectionHandler) HandleDeleteCollectionImage(w http.ResponseWriter, r *http.Request) {
collectionID := chi.URLParam(r, "id")
if collectionID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Collection ID is required")
return
}
imageType := r.URL.Query().Get("type")
switch imageType {
case "poster", "backdrop":
default:
writeError(w, http.StatusBadRequest, "bad_request", "type must be \"poster\" or \"backdrop\"")
return
}
if err := h.deleteCollectionImages(r.Context(), collectionID, imageType); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete images")
return
}
empty := ""
notAutoGen := false
notFromTemplate := false
input := catalog.UpdateLibraryCollectionInput{ID: collectionID}
if imageType == "poster" {
input.PosterURL = &empty
input.PosterThumbhash = &empty
input.PosterAutoGenerated = &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
}
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 = intersectCollectionLibraryIDs(def.LibraryIDs, collection.LibraryIDs)
if len(def.LibraryIDs) == 0 {
return []itemListResponse{}, nil
}
case collection.LibraryID > 0:
def.LibraryIDs = intersectCollectionLibraryIDs(def.LibraryIDs, []int{collection.LibraryID})
if len(def.LibraryIDs) == 0 {
return []itemListResponse{}, nil
}
}
items, total, err := h.Executor.Preview(r.Context(), def, requestAccessFilter(r), 1)
if err != nil {
return nil, err
}
if total == 0 {
return []itemListResponse{}, nil
}
if total > len(items) {
items, _, err = h.Executor.Preview(r.Context(), def, requestAccessFilter(r), total)
if err != nil {
return nil, err
}
}
resp := make([]itemListResponse, 0, len(items))
for _, item := range items {
resp = append(resp, h.toItemListResponse(r, item))
}
return resp, nil
}
type smartCollectionQueryError struct {
err error
}
func (e smartCollectionQueryError) Error() string {
return e.err.Error()
}
func (e smartCollectionQueryError) Unwrap() error {
return e.err
}
func intersectCollectionLibraryIDs(existing, required []int) []int {
if len(required) == 0 {
return append([]int(nil), existing...)
}
if len(existing) == 0 {
return append([]int(nil), required...)
}
allowed := make(map[int]struct{}, len(required))
for _, id := range required {
allowed[id] = struct{}{}
}
result := make([]int, 0, len(existing))
seen := make(map[int]struct{}, len(existing))
for _, id := range existing {
if _, ok := allowed[id]; !ok {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
result = append(result, id)
}
return result
}
func (h *LibraryCollectionHandler) toItemListResponse(r *http.Request, item *models.MediaItem) itemListResponse {
resp := itemListResponse{
ContentID: item.ContentID,
Type: item.Type,
Title: item.Title,
Year: item.Year,
Genres: item.Genres,
ContentRating: item.ContentRating,
Status: item.Status,
RatingIMDB: item.RatingIMDB,
Overview: item.Overview,
PosterThumbhash: item.PosterThumbhash,
BackdropThumbhash: item.BackdropThumbhash,
}
resp.PosterURL = h.presignURL(r, cardThumbnailPath(item.PosterPath), "card")
resp.BackdropURL = h.presignURL(r, cardThumbnailPath(item.BackdropPath), "card")
return resp
}
func (h *LibraryCollectionHandler) presignURL(r *http.Request, path string, variant string) string {
if h.detailSvc != nil {
return h.detailSvc.PresignURL(r.Context(), path, variant)
}
return ""
}
func (h *LibraryCollectionHandler) presignGPURL(r *http.Request, path string) string {
if path == "" {
return ""
}
if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") {
return path
}
if strings.HasPrefix(path, "/") {
return path
}
if h.s3GP == nil {
return ""
}
url, err := h.s3GP.PresignGetURL(r.Context(), h.s3GP.Bucket(), cardThumbnailPath(path), h.presignTTL)
if err != nil {
return ""
}
return url
}
func (h *LibraryCollectionHandler) toLibraryCollectionResponse(r *http.Request, collection *models.LibraryCollection) libraryCollectionResponse {
resp := libraryCollectionResponse{
ID: collection.ID,
LibraryID: collection.LibraryID,
LibraryIDs: append([]int(nil), collection.LibraryIDs...),
Slug: collection.Slug,
Title: collection.Title,
Description: collection.Description,
CollectionType: collection.CollectionType,
Visibility: collection.Visibility,
SortOrder: collection.SortOrder,
GroupID: collection.GroupID,
Featured: collection.Featured,
PosterURL: h.presignGPURL(r, collection.PosterURL),
BackdropURL: h.presignGPURL(r, collection.BackdropURL),
PosterThumbhash: collection.PosterThumbhash,
BackdropThumbhash: collection.BackdropThumbhash,
SourceURL: collection.SourceURL,
QueryDefinition: defaultJSON(collection.QueryDefinition),
SortConfig: defaultJSON(collection.SortConfig),
SourceConfig: collection.SourceConfig,
ManagementMode: collection.ManagementMode,
ManagementSource: collection.ManagementSource,
ManagementKey: collection.ManagementKey,
LastSyncStatus: collection.LastSyncStatus,
LastSyncMessage: collection.LastSyncMessage,
ItemCount: collection.ItemCount,
CreatedAt: collection.CreatedAt.Format(time.RFC3339),
UpdatedAt: collection.UpdatedAt.Format(time.RFC3339),
}
if collection.LastSyncAt != nil {
resp.LastSyncAt = collection.LastSyncAt.Format(time.RFC3339)
}
if collection.SyncSchedule != nil {
resp.SyncSchedule = *collection.SyncSchedule
}
if collection.NextSyncAt != nil {
resp.NextSyncAt = collection.NextSyncAt.Format(time.RFC3339)
}
return resp
}
func (h *LibraryCollectionHandler) toLibraryCollectionResponses(r *http.Request, collections []*models.LibraryCollection) []libraryCollectionResponse {
out := make([]libraryCollectionResponse, 0, len(collections))
for _, collection := range collections {
out = append(out, h.toLibraryCollectionResponse(r, collection))
}
return out
}
func parsePathLibraryID(w http.ResponseWriter, r *http.Request) (int, bool) {
libraryID, err := strconv.Atoi(chi.URLParam(r, "id"))
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid library ID")
return 0, false
}
return libraryID, true
}
func requestCanAccessLibrary(r *http.Request, libraryID int) bool {
scope, ok := access.GetScope(r.Context())
if !ok || scope.AllowedLibraryIDs == nil {
return true
}
return slices.Contains(scope.AllowedLibraryIDs, libraryID)
}
func slugifyCollectionName(title string) string {
title = strings.ToLower(strings.TrimSpace(title))
var builder strings.Builder
lastHyphen := false
for _, r := range title {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
builder.WriteRune(r)
lastHyphen = false
continue
}
if !lastHyphen {
builder.WriteByte('-')
lastHyphen = true
}
}
return strings.Trim(builder.String(), "-")
}
func defaultCollectionVisibility(visibility string) string {
if visibility == "" {
return "visible"
}
return visibility
}
func normalizeCollectionManagementMode(mode string) string {
switch strings.TrimSpace(strings.ToLower(mode)) {
case collectionManagementModeSection, collectionManagementModeTemplateBundle:
return strings.TrimSpace(strings.ToLower(mode))
default:
return collectionManagementModeManual
}
}
func normalizeCollectionManagementFields(mode, source, key string) (string, string, string, error) {
normalizedMode := normalizeCollectionManagementMode(mode)
normalizedSource := strings.TrimSpace(source)
normalizedKey := strings.TrimSpace(key)
if normalizedMode != collectionManagementModeManual && normalizedKey == "" {
return "", "", "", fmt.Errorf("management_key is required for managed collections")
}
if normalizedMode == collectionManagementModeManual {
normalizedSource = ""
normalizedKey = ""
}
return normalizedMode, normalizedSource, normalizedKey, nil
}
func normalizeOptionalCollectionManagementMode(mode *string) *string {
if mode == nil {
return nil
}
normalized := normalizeCollectionManagementMode(*mode)
return &normalized
}
func defaultCollectionSourceConfig(config json.RawMessage) json.RawMessage {
if len(config) == 0 {
return json.RawMessage(`{}`)
}
return config
}
func buildMDBListSourceConfig(url string, limit *int) (json.RawMessage, error) {
payload := struct {
Mode string `json:"mode"`
URL string `json:"url"`
Limit *int `json:"limit,omitempty"`
}{
Mode: "mdblist_json",
URL: url,
}
if limit != nil && *limit > 0 {
payload.Limit = limit
}
raw, err := json.Marshal(payload)
if err != nil {
return nil, err
}
return raw, nil
}
func buildTMDBSourceConfig(preset, mediaType, timeWindow string, limit *int) (json.RawMessage, error) {
payload := struct {
Mode string `json:"mode"`
Preset string `json:"preset"`
MediaType string `json:"media_type"`
TimeWindow string `json:"time_window,omitempty"`
Limit *int `json:"limit,omitempty"`
}{
Mode: "tmdb_preset",
Preset: preset,
MediaType: mediaType,
Limit: limit,
}
if preset == "trending" {
payload.TimeWindow = timeWindow
}
raw, err := json.Marshal(payload)
if err != nil {
return nil, err
}
return raw, nil
}
// buildTMDBCollectionSourceConfig emits the `source_config` JSON for a TMDB
// franchise/saga collection. CollectionID == 0 is permitted and round-trips
// through omitempty as no key at all — the catalog sync path uses that to
// detect a placeholder template that an admin still needs to configure.
func buildTMDBCollectionSourceConfig(collectionID int, limit *int) (json.RawMessage, error) {
payload := struct {
Mode string `json:"mode"`
CollectionID int `json:"collection_id,omitempty"`
Limit *int `json:"limit,omitempty"`
}{
Mode: collectionSourceModeTMDBCollection,
CollectionID: collectionID,
Limit: limit,
}
raw, err := json.Marshal(payload)
if err != nil {
return nil, err
}
return raw, nil
}
// buildTMDBCollectionSourceURL composes the deterministic source URL string
// stored alongside the collection. Mirrors buildTMDBSourceURL.
func buildTMDBCollectionSourceURL(collectionID int) string {
return fmt.Sprintf("tmdb://collection/%d", collectionID)
}
// tmdbDiscoverConfigBody is the on-disk shape of the `discover` object inside
// a tmdb_discover collection's source_config. Field names match the
// templates.TMDBDiscoverSpec JSON contract so the catalog sync can decode the
// same JSON either way.
type tmdbDiscoverConfigBody struct {
WithGenres []int `json:"with_genres,omitempty"`
WithoutGenres []int `json:"without_genres,omitempty"`
SortBy string `json:"sort_by"`
VoteCountGte int `json:"vote_count_gte,omitempty"`
VoteAverageGte float64 `json:"vote_average_gte,omitempty"`
ReleaseDateGte string `json:"release_date_gte,omitempty"`
ReleaseDateLte string `json:"release_date_lte,omitempty"`
Certifications []string `json:"certifications,omitempty"`
CertificationLte string `json:"certification_lte,omitempty"`
WithRuntimeGte int `json:"with_runtime_gte,omitempty"`
WithRuntimeLte int `json:"with_runtime_lte,omitempty"`
OriginalLanguage string `json:"original_language,omitempty"`
}
// buildTMDBDiscoverSourceConfig emits the `source_config` JSON for a TMDB
// discover collection. The on-disk shape is:
//
// { "mode":"tmdb_discover", "media_type":"movie",
// "discover": { "sort_by":"...", ... }, "limit": 50 }
//
// The discover sub-object exists so additional discover-only fields don't
// pollute the top-level libraryCollectionSourceConfig struct used for the
// existing tmdb_preset / trakt_preset / mdblist_json modes.
func buildTMDBDiscoverSourceConfig(mediaType string, spec importTMDBDiscoverSpecBody, limit *int) (json.RawMessage, error) {
payload := struct {
Mode string `json:"mode"`
MediaType string `json:"media_type"`
Limit *int `json:"limit,omitempty"`
Discover tmdbDiscoverConfigBody `json:"discover"`
}{
Mode: collectionSourceModeTMDBDiscover,
MediaType: mediaType,
Limit: limit,
Discover: tmdbDiscoverConfigBody{
WithGenres: spec.WithGenres,
WithoutGenres: spec.WithoutGenres,
SortBy: spec.SortBy,
VoteCountGte: spec.VoteCountGte,
VoteAverageGte: spec.VoteAverageGte,
ReleaseDateGte: spec.ReleaseDateGte,
ReleaseDateLte: spec.ReleaseDateLte,
Certifications: spec.Certifications,
CertificationLte: spec.CertificationLte,
WithRuntimeGte: spec.WithRuntimeGte,
WithRuntimeLte: spec.WithRuntimeLte,
OriginalLanguage: spec.OriginalLanguage,
},
}
raw, err := json.Marshal(payload)
if err != nil {
return nil, err
}
return raw, nil
}
// buildTMDBDiscoverSourceURL composes a deterministic source URL string for
// a TMDB discover collection. The URL is purely informational — sync uses
// source_config.
func buildTMDBDiscoverSourceURL(mediaType, sortBy string) string {
return fmt.Sprintf("tmdb://discover/%s?sort_by=%s", mediaType, sortBy)
}
func buildTraktSourceConfig(preset, mediaType, profileID string, limit *int) (json.RawMessage, error) {
payload := struct {
Mode string `json:"mode"`
Provider string `json:"provider"`
Preset string `json:"preset"`
MediaType string `json:"media_type"`
ProfileID string `json:"profile_id,omitempty"`
Limit *int `json:"limit,omitempty"`
}{
Mode: "trakt_preset",
Provider: "trakt",
Preset: preset,
MediaType: mediaType,
ProfileID: profileID,
Limit: limit,
}
raw, err := json.Marshal(payload)
if err != nil {
return nil, err
}
return raw, nil
}
func normalizeTMDBPresetRequest(preset, mediaType, timeWindow string) (string, string, string, error) {
switch preset {
case "trending":
switch mediaType {
case "movie", "tv", "all":
default:
return "", "", "", fmt.Errorf("media_type must be \"movie\", \"tv\", or \"all\"")
}
switch timeWindow {
case "day", "week":
default:
return "", "", "", fmt.Errorf("time_window must be \"day\" or \"week\"")
}
return preset, mediaType, timeWindow, nil
case "popular", "top_rated":
switch mediaType {
case "movie", "tv":
return preset, mediaType, "", nil
default:
return "", "", "", fmt.Errorf("media_type must be \"movie\" or \"tv\" for preset %q", preset)
}
case "now_playing", "upcoming":
if mediaType != "movie" {
return "", "", "", fmt.Errorf("media_type must be \"movie\" for preset %q", preset)
}
return preset, mediaType, "", nil
case "airing_today", "on_the_air":
if mediaType != "tv" {
return "", "", "", fmt.Errorf("media_type must be \"tv\" for preset %q", preset)
}
return preset, mediaType, "", nil
default:
return "", "", "", fmt.Errorf("preset must be one of \"trending\", \"popular\", \"top_rated\", \"now_playing\", \"upcoming\", \"airing_today\", or \"on_the_air\"")
}
}
func normalizeTraktPresetRequest(preset, mediaType, profileID string) (string, string, string, error) {
preset = strings.TrimSpace(strings.ToLower(preset))
mediaType = strings.TrimSpace(strings.ToLower(mediaType))
profileID = strings.TrimSpace(profileID)
switch preset {
case "trending", "popular":
case "recommended":
if profileID == "" {
return "", "", "", fmt.Errorf("profile_id is required for Trakt recommended collections")
}
default:
return "", "", "", fmt.Errorf("preset must be one of \"trending\", \"popular\", or \"recommended\"")
}
switch mediaType {
case "movie", "tv":
default:
return "", "", "", fmt.Errorf("media_type must be \"movie\" or \"tv\"")
}
if preset != "recommended" {
profileID = ""
}
return preset, mediaType, profileID, nil
}
func buildTMDBSourceURL(preset, mediaType, timeWindow string) string {
if preset == "trending" {
return fmt.Sprintf("tmdb://%s/%s/%s", preset, mediaType, timeWindow)
}
return fmt.Sprintf("tmdb://%s/%s", preset, mediaType)
}
func buildTraktSourceURL(preset, mediaType, profileID string) string {
if preset == "recommended" {
return fmt.Sprintf("trakt://%s/%s/%s", preset, mediaType, profileID)
}
return fmt.Sprintf("trakt://%s/%s", preset, mediaType)
}
func (h *LibraryCollectionHandler) processCollectionImage(
ctx context.Context,
collectionID string,
imageType string,
fileData []byte,
) (s3Path string, thumbhashStr string, err error) {
return uploadCollectionImageVariants(ctx, h.s3GP, adminCollectionImagePrefix, collectionID, imageType, fileData)
}
func (h *LibraryCollectionHandler) deleteCollectionImages(ctx context.Context, collectionID, imageType string) error {
return removeCollectionImageVariants(ctx, h.s3GP, adminCollectionImagePrefix, collectionID, imageType)
}
func (h *LibraryCollectionHandler) processArtworkInputs(r *http.Request, collectionID, posterSourceURL, backdropSourceURL string) error {
sourceByType := map[string]string{
"poster": strings.TrimSpace(posterSourceURL),
"backdrop": strings.TrimSpace(backdropSourceURL),
}
isMultipart := strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/")
for _, imageType := range []string{"poster", "backdrop"} {
var (
fileData []byte
err error
)
if isMultipart {
fileData, err = readCollectionImageMultipart(r, imageType)
} else {
err = http.ErrMissingFile
}
switch {
case err == nil:
case err == http.ErrMissingFile:
if sourceByType[imageType] == "" {
continue
}
fileData, err = downloadCollectionImageURL(r.Context(), h.httpClient, sourceByType[imageType])
if err != nil {
return fmt.Errorf("%s source: %w", imageType, err)
}
default:
return fmt.Errorf("%s: %w", imageType, err)
}
if err := h.deleteCollectionImages(r.Context(), collectionID, imageType); err != nil {
return fmt.Errorf("deleting %s images: %w", imageType, err)
}
s3Path, thumbhash, err := h.processCollectionImage(r.Context(), collectionID, imageType, fileData)
if err != nil {
return fmt.Errorf("%s: %w", imageType, err)
}
posterURL, backdropURL := (*string)(nil), (*string)(nil)
posterThumbhash, backdropThumbhash := (*string)(nil), (*string)(nil)
if imageType == "poster" {
posterURL = &s3Path
posterThumbhash = &thumbhash
} else {
backdropURL = &s3Path
backdropThumbhash = &thumbhash
}
update := catalog.UpdateLibraryCollectionInput{
ID: collectionID,
PosterURL: posterURL,
BackdropURL: backdropURL,
PosterThumbhash: posterThumbhash,
BackdropThumbhash: backdropThumbhash,
}
if imageType == "poster" {
adminUpload := false
fromTemplate := false
update.PosterAutoGenerated = &adminUpload
update.PosterFromTemplate = &fromTemplate
}
if err := h.repo.Update(r.Context(), update); err != nil {
return fmt.Errorf("updating %s: %w", imageType, err)
}
}
return nil
}
func decodeJSONOrMultipart(r *http.Request, dest interface{}) error {
ct := r.Header.Get("Content-Type")
if strings.HasPrefix(ct, "multipart/") {
if err := r.ParseMultipartForm(20 << 20); err != nil {
return fmt.Errorf("parsing multipart form: %w", err)
}
dataField := r.FormValue("data")
if dataField == "" {
return fmt.Errorf("missing 'data' field in multipart form")
}
return json.Unmarshal([]byte(dataField), dest)
}
return json.NewDecoder(r.Body).Decode(dest)
}
func pointerStringValue(value *string) string {
if value == nil {
return ""
}
return *value
}