diff --git a/internal/api/handlers/library_collections.go b/internal/api/handlers/library_collections.go index c1c5bdc7..97733ee5 100644 --- a/internal/api/handlers/library_collections.go +++ b/internal/api/handlers/library_collections.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "net/http" + "runtime/debug" "slices" "sort" "strconv" @@ -167,11 +168,13 @@ func (h *LibraryCollectionHandler) GenerateCollectionPoster(ctx context.Context, } autoGen := true + notFromTemplate := false if err := h.repo.Update(ctx, catalog.UpdateLibraryCollectionInput{ ID: collectionID, PosterURL: &s3Path, PosterThumbhash: &thumbhash, PosterAutoGenerated: &autoGen, + PosterFromTemplate: ¬FromTemplate, }); err != nil { return fmt.Errorf("updating collection poster: %w", err) } @@ -337,26 +340,31 @@ type importMDBListRequest struct { 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"` + 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 @@ -365,21 +373,22 @@ type importTMDBRequest struct { // (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"` + 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 @@ -389,22 +398,23 @@ type importTMDBFranchiseRequest struct { // 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"` + 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 @@ -1262,11 +1272,13 @@ func (h *LibraryCollectionHandler) HandleDeleteCollectionImage(w http.ResponseWr empty := "" notAutoGen := false + notFromTemplate := false input := catalog.UpdateLibraryCollectionInput{ID: collectionID} if imageType == "poster" { input.PosterURL = &empty input.PosterThumbhash = &empty input.PosterAutoGenerated = ¬AutoGen + input.PosterFromTemplate = ¬FromTemplate } else { input.BackdropURL = &empty input.BackdropThumbhash = &empty @@ -1594,7 +1606,7 @@ func (h *LibraryCollectionHandler) HandleApplyTemplateBundle(w http.ResponseWrit // 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(workCtx, libraryIDs); err != nil { + if err := h.SectionRepo.DeleteGeneratedTemplateBundleFeaturedSections(workCtx, bundle.ID, libraryIDs); err != nil { slog.Error("deleting generated template bundle featured sections", "bundle_id", bundle.ID, "error", err) writeError(w, http.StatusInternalServerError, "delete_setup_failed", "Failed to clear generated featured sections before delete") return @@ -1673,6 +1685,17 @@ func (h *LibraryCollectionHandler) HandleApplyTemplateBundle(w http.ResponseWrit 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, @@ -1683,7 +1706,11 @@ func (h *LibraryCollectionHandler) HandleApplyTemplateBundle(w http.ResponseWrit h.ensureTemplatePoster(workCtx, existingBySlug, tmpl) } entry.CollectionID = existingBySlug.ID - entry.Reason = "already_exists" + 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, @@ -1720,12 +1747,17 @@ func (h *LibraryCollectionHandler) HandleApplyTemplateBundle(w http.ResponseWrit 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)) { created = templateBundleCreatedEntries(pendingSyncs) - queued, failed := h.queueTemplateBundleSyncs(workCtx, pendingSyncs) + queued := templateBundleQueuedEntries(pendingSyncs) resp.Created = append(resp.Created, created...) resp.SyncQueued = append(resp.SyncQueued, queued...) - resp.Failed = append(resp.Failed, failed...) h.syncTemplateBundleCollectionsAsync(bundle.ID, pendingSyncs) } else { var failed []templateBundleApplyEntry @@ -1770,16 +1802,25 @@ func (h *LibraryCollectionHandler) ensureTemplatePoster( if posterPath == "" || collection == nil { return } - if collection.PosterURL != "" && !collection.PosterAutoGenerated { + // 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 } emptyThumbhash := "" notAutoGenerated := false + fromTemplate := true if err := h.repo.Update(ctx, catalog.UpdateLibraryCollectionInput{ ID: collection.ID, PosterURL: &posterPath, PosterThumbhash: &emptyThumbhash, PosterAutoGenerated: ¬AutoGenerated, + PosterFromTemplate: &fromTemplate, }); err != nil { slog.Warn("failed to apply template poster", "collection_id", collection.ID, @@ -1802,28 +1843,27 @@ func templateBundleCreatedEntries(pending []pendingTemplateBundleSync) []templat return entries } -func (h *LibraryCollectionHandler) queueTemplateBundleSyncs( - ctx context.Context, - pending []pendingTemplateBundleSync, -) ([]templateBundleApplyEntry, []templateBundleApplyEntry) { - now := time.Now() - queued := make([]templateBundleApplyEntry, 0, len(pending)) - failed := make([]templateBundleApplyEntry, 0) +// templateBundleQueuedEntries marks each pending sync with a reason describing +// what the background goroutine will do with it. Items without a cron schedule +// (e.g. the tmdb_franchise_placeholder template) still receive an async sync +// attempt, but the catalog scheduler won't re-fire them — surface that to the +// admin so they know the placeholder still needs configuration. +// +// 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 { - if item.SyncSchedule == nil || strings.TrimSpace(*item.SyncSchedule) == "" { - continue - } - if err := h.repo.UpdateNextSyncAt(ctx, item.CollectionID, &now); err != nil { - entry := item.Entry - entry.Reason = fmt.Sprintf("queue sync: %v", err) - failed = append(failed, entry) - continue - } entry := item.Entry - entry.Reason = "sync_queued" - queued = append(queued, 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 queued, failed + return entries } func (h *LibraryCollectionHandler) syncTemplateBundleCollectionsAsync( @@ -1835,6 +1875,16 @@ func (h *LibraryCollectionHandler) syncTemplateBundleCollectionsAsync( } 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() @@ -1895,8 +1945,20 @@ func (h *LibraryCollectionHandler) syncTemplateBundleCollections( eg.SetLimit(templateBundleSyncConcurrency) for i, item := range pending { i, item := i, item - eg.Go(func() error { - _, err := h.service.SyncCollectionWithOptions(ctx, item.CollectionID, catalog.SyncCollectionOptions{ + 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} @@ -1933,6 +1995,15 @@ func (h *LibraryCollectionHandler) generateTemplateBundleCollagesAsync(entries [ } 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() @@ -1940,7 +2011,17 @@ func (h *LibraryCollectionHandler) generateTemplateBundleCollagesAsync(entries [ eg.SetLimit(templateBundleCollageConcurrency) for _, collectionID := range collectionIDs { collectionID := collectionID - eg.Go(func() error { + 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) @@ -2177,56 +2258,59 @@ func (h *LibraryCollectionHandler) createCollectionFromTemplate( 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, + 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, + 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, + 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 { @@ -2251,14 +2335,15 @@ func (h *LibraryCollectionHandler) createCollectionFromTemplate( 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, + 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) @@ -2294,15 +2379,16 @@ func (h *LibraryCollectionHandler) createMDBListCollection( Description: req.Description, CollectionType: "mdblist", Visibility: "visible", - Featured: req.Featured, - SortOrder: req.SortOrder, - PosterURL: req.PosterURL, - SourceURL: req.URL, - SourceConfig: sourceConfig, - ManagementMode: managementMode, - ManagementSource: managementSource, - ManagementKey: managementKey, - SyncSchedule: syncSchedule, + 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) @@ -2343,15 +2429,16 @@ func (h *LibraryCollectionHandler) createTMDBCollection( Description: req.Description, CollectionType: "tmdb", Visibility: "visible", - Featured: req.Featured, - SortOrder: req.SortOrder, - PosterURL: req.PosterURL, - SourceURL: buildTMDBSourceURL(preset, mediaType, timeWindow), - SourceConfig: sourceConfig, - ManagementMode: managementMode, - ManagementSource: managementSource, - ManagementKey: managementKey, - SyncSchedule: syncSchedule, + 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) @@ -2401,15 +2488,16 @@ func (h *LibraryCollectionHandler) createTMDBFranchiseCollection( Description: req.Description, CollectionType: "tmdb", Visibility: "visible", - Featured: req.Featured, - SortOrder: req.SortOrder, - PosterURL: req.PosterURL, - SourceURL: buildTMDBCollectionSourceURL(req.CollectionID), - SourceConfig: sourceConfig, - ManagementMode: managementMode, - ManagementSource: managementSource, - ManagementKey: managementKey, - SyncSchedule: syncSchedule, + 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) @@ -2453,15 +2541,16 @@ func (h *LibraryCollectionHandler) createTMDBDiscoverCollection( Description: req.Description, CollectionType: "tmdb", Visibility: "visible", - Featured: req.Featured, - SortOrder: req.SortOrder, - PosterURL: req.PosterURL, - SourceURL: buildTMDBDiscoverSourceURL(req.MediaType, req.Spec.SortBy), - SourceConfig: sourceConfig, - ManagementMode: managementMode, - ManagementSource: managementSource, - ManagementKey: managementKey, - SyncSchedule: syncSchedule, + 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) @@ -3295,7 +3384,9 @@ func (h *LibraryCollectionHandler) processArtworkInputs(r *http.Request, collect } 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) diff --git a/internal/catalog/library_collection_repo.go b/internal/catalog/library_collection_repo.go index 8943a333..e3510304 100644 --- a/internal/catalog/library_collection_repo.go +++ b/internal/catalog/library_collection_repo.go @@ -34,6 +34,7 @@ type CreateLibraryCollectionInput struct { PosterThumbhash string BackdropThumbhash string PosterAutoGenerated bool + PosterFromTemplate bool SourceURL string QueryDefinition json.RawMessage SortConfig json.RawMessage @@ -60,6 +61,7 @@ type UpdateLibraryCollectionInput struct { PosterThumbhash *string BackdropThumbhash *string PosterAutoGenerated *bool + PosterFromTemplate *bool SourceURL *string QueryDefinition json.RawMessage SortConfig json.RawMessage @@ -104,7 +106,7 @@ func NewLibraryCollectionRepository(pool *pgxpool.Pool) *LibraryCollectionReposi const libraryCollectionColumns = `lc.id, lc.library_id, lc.slug, lc.title, lc.description, lc.collection_type, lc.visibility, COALESCE(scope_lcl.sort_order, lc.sort_order), scope_lcl.group_id, lc.featured, lc.poster_url, - lc.backdrop_url, lc.poster_thumbhash, lc.backdrop_thumbhash, lc.poster_auto_generated, + lc.backdrop_url, lc.poster_thumbhash, lc.backdrop_thumbhash, lc.poster_auto_generated, lc.poster_from_template, lc.source_url, lc.query_definition, lc.sort_config, lc.source_config, lc.management_mode, lc.management_source, lc.management_key, lc.last_sync_status, lc.last_sync_message, lc.last_sync_at, lc.sync_schedule, lc.next_sync_at, @@ -140,6 +142,7 @@ func scanLibraryCollection(row pgx.Row) (*models.LibraryCollection, error) { &collection.PosterThumbhash, &collection.BackdropThumbhash, &collection.PosterAutoGenerated, + &collection.PosterFromTemplate, &collection.SourceURL, &collection.QueryDefinition, &collection.SortConfig, @@ -216,17 +219,17 @@ func (r *LibraryCollectionRepository) Create(ctx context.Context, input CreateLi INSERT INTO library_collections ( id, library_id, slug, title, description, collection_type, visibility, sort_order, group_label, featured, poster_url, backdrop_url, - poster_thumbhash, backdrop_thumbhash, poster_auto_generated, + poster_thumbhash, backdrop_thumbhash, poster_auto_generated, poster_from_template, source_url, query_definition, sort_config, source_config, management_mode, management_source, management_key, sync_schedule, next_sync_at ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, '', $9, $10, $11, - $12, $13, $14, - $15, $16, $17, $18, - $19, $20, $21, - $22, $23 + $12, $13, $14, $15, + $16, $17, $18, $19, + $20, $21, $22, + $23, $24 ) RETURNING id` @@ -256,6 +259,7 @@ func (r *LibraryCollectionRepository) Create(ctx context.Context, input CreateLi input.PosterThumbhash, input.BackdropThumbhash, input.PosterAutoGenerated, + input.PosterFromTemplate, input.SourceURL, input.QueryDefinition, input.SortConfig, @@ -470,6 +474,11 @@ func (r *LibraryCollectionRepository) Update(ctx context.Context, input UpdateLi args = append(args, *input.PosterAutoGenerated) argIdx++ } + if input.PosterFromTemplate != nil { + sets = append(sets, fmt.Sprintf("poster_from_template = $%d", argIdx)) + args = append(args, *input.PosterFromTemplate) + argIdx++ + } if input.SourceURL != nil { sets = append(sets, fmt.Sprintf("source_url = $%d", argIdx)) args = append(args, *input.SourceURL) diff --git a/internal/catalog/library_collection_service.go b/internal/catalog/library_collection_service.go index 76fe0feb..98f11d89 100644 --- a/internal/catalog/library_collection_service.go +++ b/internal/catalog/library_collection_service.go @@ -247,6 +247,14 @@ func (s *LibraryCollectionService) syncMDBListCollection(ctx context.Context, co return nil, err } + // Trim the entry list to the same fetch-multiplier bound used for TMDB and + // Trakt sources before building the external-ID batches. MDBList lists can + // return hundreds of entries; without this, the two GetByExternalIDs IN + // arrays balloon to the full list size even when the user's limit is small. + if fetchLimit := collectionutil.SourceFetchLimit(limit); fetchLimit > 0 && len(entries) > fetchLimit { + entries = entries[:fetchLimit] + } + // Pre-fetch all external-ID lookups grouped by item type (movie vs series) // in two batched queries instead of up to 3×N GetByExternalID calls // (audit 2026-05-01 §3.7). @@ -337,6 +345,7 @@ func (s *LibraryCollectionService) syncMDBListCollection(ctx context.Context, co } scannedEntries := 0 + limitReached := false for index, entry := range entries { scannedEntries = index + 1 r, ok := resolvedByIndex[index] @@ -361,6 +370,7 @@ func (s *LibraryCollectionService) syncMDBListCollection(ctx context.Context, co SourceRank: r.sourceRank, }) if collectionutil.ItemLimitReached(len(matchedItems), limit) { + limitReached = true break } } @@ -378,11 +388,20 @@ func (s *LibraryCollectionService) syncMDBListCollection(ctx context.Context, co return nil, fmt.Errorf("marshaling sync warnings: %w", err) } + // Report the full source size as the denominator so operators can tell + // "limit reached" apart from "perfect coverage of a small source"; the + // extra clause exposes how many entries were actually scanned before the + // break. + message := fmt.Sprintf("Matched %d of %d entries", len(matchedItems), len(entries)) + if limitReached { + message = fmt.Sprintf("%s (item limit reached after %d scanned)", message, scannedEntries) + } + completedAt := syncTimestamp() run, err := s.collections.RecordSyncRun(ctx, RecordLibraryCollectionSyncRunInput{ CollectionID: collection.ID, Status: status, - Message: fmt.Sprintf("Matched %d of %d entries", len(matchedItems), scannedEntries), + Message: message, ItemsAdded: len(matchedItems), ItemsRemoved: 0, ItemsMatched: len(matchedItems), @@ -446,6 +465,7 @@ func (s *LibraryCollectionService) syncTMDBPresetCollection(ctx context.Context, unmatchedCount := 0 duplicateCount := 0 scannedEntries := 0 + limitReached := false for i, entry := range results { scannedEntries = i + 1 @@ -498,6 +518,7 @@ func (s *LibraryCollectionService) syncTMDBPresetCollection(ctx context.Context, SourceRank: i + 1, }) if collectionutil.ItemLimitReached(len(matchedItems), cfg.Limit) { + limitReached = true break } } @@ -508,7 +529,8 @@ func (s *LibraryCollectionService) syncTMDBPresetCollection(ctx context.Context, "matched", len(matchedItems), "unmatched", unmatchedCount, "duplicates", duplicateCount, - "total", scannedEntries, + "scanned", scannedEntries, + "total", len(results), ) if err := s.collections.ReplaceItems(ctx, collection.ID, matchedItems); err != nil { @@ -519,7 +541,10 @@ func (s *LibraryCollectionService) syncTMDBPresetCollection(ctx context.Context, if len(warnings) > 0 { status = "warning" } - message := fmt.Sprintf("Matched %d of %d entries", len(matchedItems), scannedEntries) + message := fmt.Sprintf("Matched %d of %d entries", len(matchedItems), len(results)) + if limitReached { + message = fmt.Sprintf("%s (item limit reached after %d scanned)", message, scannedEntries) + } if duplicateCount > 0 { message = fmt.Sprintf("%s (%d duplicates skipped)", message, duplicateCount) } @@ -604,6 +629,7 @@ func (s *LibraryCollectionService) syncTMDBFranchiseCollection(ctx context.Conte unmatchedCount := 0 duplicateCount := 0 scannedEntries := 0 + limitReached := false for i, entry := range results { scannedEntries = i + 1 @@ -647,6 +673,7 @@ func (s *LibraryCollectionService) syncTMDBFranchiseCollection(ctx context.Conte SourceRank: i + 1, }) if collectionutil.ItemLimitReached(len(matchedItems), cfg.Limit) { + limitReached = true break } } @@ -657,7 +684,8 @@ func (s *LibraryCollectionService) syncTMDBFranchiseCollection(ctx context.Conte "matched", len(matchedItems), "unmatched", unmatchedCount, "duplicates", duplicateCount, - "total", scannedEntries, + "scanned", scannedEntries, + "total", len(results), ) if err := s.collections.ReplaceItems(ctx, collection.ID, matchedItems); err != nil { @@ -668,7 +696,10 @@ func (s *LibraryCollectionService) syncTMDBFranchiseCollection(ctx context.Conte if len(warnings) > 0 { status = "warning" } - message := fmt.Sprintf("Matched %d of %d entries", len(matchedItems), scannedEntries) + message := fmt.Sprintf("Matched %d of %d entries", len(matchedItems), len(results)) + if limitReached { + message = fmt.Sprintf("%s (item limit reached after %d scanned)", message, scannedEntries) + } if duplicateCount > 0 { message = fmt.Sprintf("%s (%d duplicates skipped)", message, duplicateCount) } @@ -773,6 +804,7 @@ func (s *LibraryCollectionService) syncTMDBDiscoverCollection(ctx context.Contex unmatchedCount := 0 duplicateCount := 0 scannedEntries := 0 + limitReached := false for i, entry := range results { scannedEntries = i + 1 @@ -812,6 +844,7 @@ func (s *LibraryCollectionService) syncTMDBDiscoverCollection(ctx context.Contex SourceRank: i + 1, }) if collectionutil.ItemLimitReached(len(matchedItems), cfg.Limit) { + limitReached = true break } } @@ -822,7 +855,8 @@ func (s *LibraryCollectionService) syncTMDBDiscoverCollection(ctx context.Contex "matched", len(matchedItems), "unmatched", unmatchedCount, "duplicates", duplicateCount, - "total", scannedEntries, + "scanned", scannedEntries, + "total", len(results), ) if err := s.collections.ReplaceItems(ctx, collection.ID, matchedItems); err != nil { @@ -833,7 +867,10 @@ func (s *LibraryCollectionService) syncTMDBDiscoverCollection(ctx context.Contex if len(warnings) > 0 { status = "warning" } - message := fmt.Sprintf("Matched %d of %d entries", len(matchedItems), scannedEntries) + message := fmt.Sprintf("Matched %d of %d entries", len(matchedItems), len(results)) + if limitReached { + message = fmt.Sprintf("%s (item limit reached after %d scanned)", message, scannedEntries) + } if duplicateCount > 0 { message = fmt.Sprintf("%s (%d duplicates skipped)", message, duplicateCount) } @@ -929,6 +966,7 @@ func (s *LibraryCollectionService) syncTraktPresetCollection(ctx context.Context unmatchedCount := 0 duplicateCount := 0 scannedEntries := 0 + limitReached := false for i, entry := range results { scannedEntries = i + 1 @@ -963,6 +1001,7 @@ func (s *LibraryCollectionService) syncTraktPresetCollection(ctx context.Context SourceRank: sourceRank, }) if collectionutil.ItemLimitReached(len(matchedItems), cfg.Limit) { + limitReached = true break } } @@ -975,7 +1014,10 @@ func (s *LibraryCollectionService) syncTraktPresetCollection(ctx context.Context if len(warnings) > 0 { status = "warning" } - message := fmt.Sprintf("Matched %d of %d entries", len(matchedItems), scannedEntries) + message := fmt.Sprintf("Matched %d of %d entries", len(matchedItems), len(results)) + if limitReached { + message = fmt.Sprintf("%s (item limit reached after %d scanned)", message, scannedEntries) + } if duplicateCount > 0 { message = fmt.Sprintf("%s (%d duplicates skipped)", message, duplicateCount) } diff --git a/internal/models/library_collection.go b/internal/models/library_collection.go index ffebe0e7..b1e47295 100644 --- a/internal/models/library_collection.go +++ b/internal/models/library_collection.go @@ -23,6 +23,12 @@ type LibraryCollection struct { PosterThumbhash string BackdropThumbhash string PosterAutoGenerated bool + // PosterFromTemplate flags posters set by a template bundle apply. + // ensureTemplatePoster sets this true; the guard treats template-origin + // posters as refreshable so template poster updates propagate, while + // admin-uploaded posters (PosterAutoGenerated=false AND PosterFromTemplate=false) + // remain sticky. + PosterFromTemplate bool SourceURL string QueryDefinition json.RawMessage SortConfig json.RawMessage diff --git a/internal/sections/repo.go b/internal/sections/repo.go index 84b655e0..57464d6b 100644 --- a/internal/sections/repo.go +++ b/internal/sections/repo.go @@ -199,8 +199,10 @@ func (r *Repository) GetGeneratedTemplateBundleFeaturedSection(ctx context.Conte // DeleteGeneratedTemplateBundleFeaturedSections removes generated featured // sections tied to selected libraries so bundle collection replacement is not -// blocked by stale generated section references from any previous bundle. -func (r *Repository) DeleteGeneratedTemplateBundleFeaturedSections(ctx context.Context, libraryIDs []int) error { +// blocked by stale generated section references. Scoped to a specific +// bundleID — featured sections produced by sibling bundles in the same +// libraries are left intact so multi-bundle setups coexist cleanly. +func (r *Repository) DeleteGeneratedTemplateBundleFeaturedSections(ctx context.Context, bundleID string, libraryIDs []int) error { if len(libraryIDs) == 0 { return nil } @@ -208,15 +210,16 @@ func (r *Repository) DeleteGeneratedTemplateBundleFeaturedSections(ctx context.C DELETE FROM page_sections WHERE section_type = $1 AND config->>'generated_source' = 'template_bundle_featured' + AND config->>'template_bundle' = $2 AND ( - (scope = 'library' AND library_id = ANY($2::int[])) + (scope = 'library' AND library_id = ANY($3::int[])) OR ( scope = 'home' AND config->>'library_id' ~ '^[0-9]+$' - AND (config->>'library_id')::int = ANY($2::int[]) + AND (config->>'library_id')::int = ANY($3::int[]) ) ) - `, SectionCollection, libraryIDs) + `, SectionCollection, bundleID, libraryIDs) if err != nil { return fmt.Errorf("deleting generated template bundle featured sections: %w", err) } diff --git a/internal/usercollections/sync.go b/internal/usercollections/sync.go index 359f9460..23e34514 100644 --- a/internal/usercollections/sync.go +++ b/internal/usercollections/sync.go @@ -173,7 +173,7 @@ func (s *Service) syncMDBList(ctx context.Context, store userstore.UserStore, co return nil, nil, err } matched = limitCollectionItems(matched, cfg.Limit) - return s.applyResult(ctx, store, collection, startedAt, matched, scanned, unmatched+droppedByLib) + return s.applyResult(ctx, store, collection, startedAt, matched, len(entries), scanned, unmatched+droppedByLib) } func mdbListItemType(entry mdblistEntry) string { @@ -266,6 +266,15 @@ func resolveMatchedWithLimit(total int, limit *int, resolve func(i int) string) return matched, unmatched, scanned } +// collectionResolveLimit returns the limit to pass into resolveMatchedWithLimit. +// +// When LibraryIDs is set we cannot break early on cfg.Limit during the resolve +// pass: filterByLibraries runs after resolve and may drop most items, so an +// early break would leave us short of cfg.Limit final items. Returning nil +// disables the inline break; limitCollectionItems then truncates to cfg.Limit +// after filtering. This trades a wider GetItemsInFolders IN-array (bounded by +// the source-fetch cap) for a correct result count when the library filter is +// selective. func collectionResolveLimit(cfg SourceConfig) *int { if len(cfg.LibraryIDs) > 0 { return nil @@ -379,7 +388,7 @@ func (s *Service) syncTMDB(ctx context.Context, store userstore.UserStore, colle return nil, nil, err } matched = limitCollectionItems(matched, cfg.Limit) - return s.applyResult(ctx, store, collection, startedAt, matched, scanned, unmatched+droppedByLib) + return s.applyResult(ctx, store, collection, startedAt, matched, len(results), scanned, unmatched+droppedByLib) } // ── Trakt presets ──────────────────────────────────────────────────────────── @@ -457,7 +466,7 @@ func (s *Service) syncTrakt(ctx context.Context, store userstore.UserStore, coll return nil, nil, err } matched = limitCollectionItems(matched, cfg.Limit) - return s.applyResult(ctx, store, collection, startedAt, matched, scanned, unmatched+droppedByLib) + return s.applyResult(ctx, store, collection, startedAt, matched, len(results), scanned, unmatched+droppedByLib) } // ── Result application ─────────────────────────────────────────────────────── @@ -468,7 +477,8 @@ func (s *Service) applyResult( collection *userstore.Collection, startedAt time.Time, matched []userstore.CollectionItemReplacement, - totalEntries int, + sourceTotal int, + scanned int, unmatched int, ) (*SyncResult, *userstore.Collection, error) { if err := store.ReplaceCollectionItems(ctx, collection.ID, matched); err != nil { @@ -480,7 +490,13 @@ func (s *Service) applyResult( if unmatched > 0 { status = "warning" } - message := fmt.Sprintf("Matched %d of %d entries", len(matched), totalEntries) + // Report the full source size as the denominator so users see + // "Matched 10 of 200" rather than "Matched 10 of 10" when the limit + // truncated mid-source; the trailing clause exposes the actual scan depth. + message := fmt.Sprintf("Matched %d of %d entries", len(matched), sourceTotal) + if scanned < sourceTotal { + message = fmt.Sprintf("%s (item limit reached after %d scanned)", message, scanned) + } var nextSyncAt *time.Time if collection.SyncSchedule != nil && *collection.SyncSchedule != "" { @@ -510,7 +526,8 @@ func (s *Service) applyResult( "status", status, "matched", len(matched), "unmatched", unmatched, - "total", totalEntries, + "scanned", scanned, + "total", sourceTotal, "duration", completedAt.Sub(startedAt).Round(time.Millisecond), ) diff --git a/internal/userstore/pgstore/collections.go b/internal/userstore/pgstore/collections.go index dcd088cb..f40242a4 100644 --- a/internal/userstore/pgstore/collections.go +++ b/internal/userstore/pgstore/collections.go @@ -113,6 +113,7 @@ func (s *PostgresUserStore) CreateCollection(ctx context.Context, input userstor SyncSchedule: input.SyncSchedule, NextSyncAt: input.NextSyncAt, IncludeInServerCollections: input.IncludeInServerCollections, + PosterURL: input.PosterURL, SortOrder: sortOrder, CreatedAt: now, UpdatedAt: now, diff --git a/migrations/134_library_collection_poster_template_origin.down.sql b/migrations/134_library_collection_poster_template_origin.down.sql new file mode 100644 index 00000000..0c901d97 --- /dev/null +++ b/migrations/134_library_collection_poster_template_origin.down.sql @@ -0,0 +1 @@ +ALTER TABLE library_collections DROP COLUMN IF EXISTS poster_from_template; diff --git a/migrations/134_library_collection_poster_template_origin.up.sql b/migrations/134_library_collection_poster_template_origin.up.sql new file mode 100644 index 00000000..a4227667 --- /dev/null +++ b/migrations/134_library_collection_poster_template_origin.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE library_collections + ADD COLUMN IF NOT EXISTS poster_from_template BOOLEAN NOT NULL DEFAULT FALSE;