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:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user