Track template poster origin in collection sync

- add poster_from_template to collection records and update flows
- preserve template-applied posters while keeping admin uploads sticky
- improve template bundle sync/status reporting and MDBList limit handling
This commit is contained in:
Silo Server Migration
2026-05-23 14:19:04 -04:00
parent e37d753a0c
commit dce8978cdf
9 changed files with 354 additions and 182 deletions
+248 -157
View File
@@ -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: &notFromTemplate,
}); 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 = &notAutoGen
input.PosterFromTemplate = &notFromTemplate
} 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: &notAutoGenerated,
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)
+15 -6
View File
@@ -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)
+50 -8
View File
@@ -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)
}
+6
View File
@@ -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
+8 -5
View File
@@ -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)
}
+23 -6
View File
@@ -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),
)
@@ -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,
@@ -0,0 +1 @@
ALTER TABLE library_collections DROP COLUMN IF EXISTS poster_from_template;
@@ -0,0 +1,2 @@
ALTER TABLE library_collections
ADD COLUMN IF NOT EXISTS poster_from_template BOOLEAN NOT NULL DEFAULT FALSE;