Port collection templates with poster support and async syncs

- Add poster URLs through collection import and template apply flows
- Queue large template bundle syncs and preserve existing collection posters
- Update template bundle assets, docs, and limit handling
This commit is contained in:
Silo Server Migration
2026-05-23 12:31:08 -04:00
parent 4a88c5ac44
commit 3eddacfe88
175 changed files with 867 additions and 145 deletions
+17 -1
View File
@@ -18,7 +18,23 @@ If a tradeoff is required, choose correctness and robustness over short-term con
Long term maintainability is a core priority. If you add new functionality, first check if there is shared logic that can be extracted to a separate module. Duplicate logic across multiple files is a code smell and should be avoided. Don't be afraid to change existing code. Don't take shortcuts by just adding local logic to solve a problem.
This repository is part of a broader multi-repo Silo workspace. Do not assume all plugin-related code lives in this repo.
This repository is part of a broader multi-repo Silo workspace. The sibling
repositories are usually checked out under `/Users/nathangray/dev/github/SiloServer`.
- `silo-server` owns the Go backend, web admin UI, API contracts, auth/session
behavior, catalog/scanner/playback services, database migrations, Jellyfin
compatibility, and host-side plugin runtime.
- `silo-android` owns the Android phone and TV clients. Client-visible API,
auth, playback, session, library, and metadata changes may require Android
follow-up.
- `silo-apple` owns the iOS, tvOS, and macOS clients. Client-visible API, auth,
playback, session, library, and metadata changes may require Apple follow-up.
When changing server behavior consumed by clients, check whether both client
repos need model, routing, playback, or UX updates. Prefer coordinated
multi-repo changes over leaving one platform behind.
Do not assume all plugin-related code lives in this repo.
- `silo-plugin-sdk` owns the public plugin SDK, protobuf contracts, generated plugin API code, manifest helpers, and runtime bootstrap.
- `silo-plugins` owns the central plugin catalog / repository manifest.
+21
View File
@@ -95,6 +95,27 @@ The poster style is intentionally close to Kometa/Plex collection posters:
black lower-third text box. Use a subtle vignette and shadow/stroke for
readability instead.
### Poster generation workflow
Generate the raw plate with Codex's `imagegen` skill / built-in image tool,
then add deterministic typography locally:
1. Prompt for a 2:3 vertical, full-bleed cinematic collection poster plate.
Include the template title and context, and explicitly require generic
original art with no readable text, logos, watermarks, real posters,
recognizable actors, franchise characters, or provider branding.
2. Copy the generated PNG into
`web/public/images/collection-templates/raw/{template_id}.png`, resizing and
center-cropping to `1024x1536`.
3. Create the final poster at
`web/public/images/collection-templates/{template_id}.jpg`, resizing and
center-cropping to `1000x1500`.
4. Add typography outside image generation: media type in gold at top-left,
collection title at bottom-left, and the source label beneath it. Use a
subtle dark vignette/overlay and text shadow or stroke for contrast.
5. Verify every built-in template has both files. The
`internal/collections/templates` tests check this.
### Trakt "Recommended" templates
The two Trakt Recommended templates require a profile that already has a
+4
View File
@@ -793,6 +793,7 @@ func toCollectionResponse(c userstore.Collection) collectionResponse {
LastSyncMessage: c.LastSyncMessage,
ItemCount: c.ItemCount,
IncludeInServerCollections: c.IncludeInServerCollections,
PosterURL: c.PosterURL,
PosterThumbhash: c.PosterThumbhash,
CreatedAt: c.CreatedAt,
UpdatedAt: c.UpdatedAt,
@@ -986,6 +987,9 @@ func (h *CollectionHandler) presignUserCollectionPoster(ctx context.Context, pat
if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") {
return path
}
if strings.HasPrefix(path, "/") {
return path
}
if h.S3GP == nil {
return ""
}
+270 -16
View File
@@ -52,8 +52,10 @@ var errLibraryCollectionInUse = errors.New("collection is used by one or more se
const (
templateBundleSyncConcurrency = 4
templateBundleInlineSyncLimit = 20
templateBundleCollageConcurrency = 2
templateBundleCollageTimeout = 5 * time.Minute
templateBundleAsyncSyncTimeout = 30 * time.Minute
collectionManagementModeManual = "manual"
collectionManagementModeSection = "section"
@@ -328,6 +330,7 @@ type importMDBListRequest struct {
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"`
@@ -347,6 +350,7 @@ type importTMDBRequest struct {
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"`
@@ -369,6 +373,7 @@ type importTMDBFranchiseRequest struct {
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"`
@@ -393,6 +398,7 @@ type importTMDBDiscoverRequest struct {
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"`
@@ -428,6 +434,7 @@ type importTraktRequest struct {
ProfileID string `json:"profile_id,omitempty"`
Limit *int `json:"limit,omitempty"`
Featured bool `json:"featured"`
PosterURL string `json:"poster_url"`
PosterSourceURL string `json:"poster_source_url"`
BackdropSourceURL string `json:"backdrop_source_url"`
SyncSchedule string `json:"sync_schedule"`
@@ -496,12 +503,14 @@ type applyTemplateBundleResponse struct {
Created []templateBundleApplyEntry `json:"created"`
Skipped []templateBundleApplyEntry `json:"skipped"`
Failed []templateBundleApplyEntry `json:"failed"`
SyncQueued []templateBundleApplyEntry `json:"sync_queued"`
Featured []templateBundleFeaturedEntry `json:"featured"`
FeaturedFailed []templateBundleFeaturedEntry `json:"featured_failed"`
}
type pendingTemplateBundleSync struct {
CollectionID string
SyncSchedule *string
Entry templateBundleApplyEntry
}
@@ -516,6 +525,11 @@ type templateBundleCollectionRefKey struct {
TemplateID string
}
type templateBundleExistingCollectionKey struct {
LibraryID int
Slug string
}
type requestValidationError struct {
err error
}
@@ -547,6 +561,44 @@ func templateBundleManagementKey(bundleID, templateID string, libraryID int) str
return fmt.Sprintf("%s:%s:library:%d", bundleID, templateID, libraryID)
}
func rememberTemplateBundleExistingCollection(
collections map[templateBundleExistingCollectionKey]*models.LibraryCollection,
libraryID int,
collection *models.LibraryCollection,
) {
if collection == nil || strings.TrimSpace(collection.Slug) == "" {
return
}
collections[templateBundleExistingCollectionKey{
LibraryID: libraryID,
Slug: collection.Slug,
}] = collection
}
func forgetTemplateBundleExistingCollection(
collections map[templateBundleExistingCollectionKey]*models.LibraryCollection,
selectedLibraryIDs map[int]struct{},
collection *models.LibraryCollection,
) {
if collection == nil || strings.TrimSpace(collection.Slug) == "" {
return
}
for _, libraryID := range collection.LibraryIDs {
if _, ok := selectedLibraryIDs[libraryID]; ok {
delete(collections, templateBundleExistingCollectionKey{
LibraryID: libraryID,
Slug: collection.Slug,
})
}
}
if len(collection.LibraryIDs) == 0 {
delete(collections, templateBundleExistingCollectionKey{
LibraryID: collection.LibraryID,
Slug: collection.Slug,
})
}
}
func templateLimitPtr(tmpl templates.Template) *int {
if tmpl.DefaultLimit <= 0 {
return nil
@@ -1499,6 +1551,7 @@ func (h *LibraryCollectionHandler) HandleApplyTemplateBundle(w http.ResponseWrit
Created: []templateBundleApplyEntry{},
Skipped: []templateBundleApplyEntry{},
Failed: []templateBundleApplyEntry{},
SyncQueued: []templateBundleApplyEntry{},
Featured: []templateBundleFeaturedEntry{},
FeaturedFailed: []templateBundleFeaturedEntry{},
}
@@ -1507,13 +1560,41 @@ func (h *LibraryCollectionHandler) HandleApplyTemplateBundle(w http.ResponseWrit
for _, libraryID := range libraryIDs {
selectedLibraryIDs[libraryID] = struct{}{}
}
collectionsByLibraryID := make(map[int][]*models.LibraryCollection, len(libraryIDs))
remainingByLibrarySlug := make(map[templateBundleExistingCollectionKey]*models.LibraryCollection)
for _, libraryID := range libraryIDs {
collections, err := h.repo.ListByLibrary(workCtx, libraryID, catalog.ListLibraryCollectionsOptions{IncludeHidden: true})
if err != nil {
library := librariesByID[libraryID]
if req.DeleteExisting {
resp.DeleteFailed = append(resp.DeleteFailed, templateBundleCollectionEntry{
LibraryID: library.ID,
LibraryName: library.Name,
Reason: err.Error(),
})
} else {
slog.Warn("listing existing collections before template bundle apply",
"bundle_id", bundle.ID,
"library_id", library.ID,
"error", err,
)
}
continue
}
collectionsByLibraryID[libraryID] = collections
for _, collection := range collections {
rememberTemplateBundleExistingCollection(remainingByLibrarySlug, libraryID, collection)
}
}
if req.DeleteExisting {
if !req.DryRun && h.SectionRepo != nil {
// Generated featured sections must go before we delete the
// collections they reference; otherwise every following delete
// trips the in-use guard and the admin sees a misleading wall of
// "collection_in_use" errors.
if err := h.SectionRepo.DeleteGeneratedTemplateBundleFeaturedSections(workCtx, bundle.ID, libraryIDs); err != nil {
if err := h.SectionRepo.DeleteGeneratedTemplateBundleFeaturedSections(workCtx, 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
@@ -1522,16 +1603,7 @@ func (h *LibraryCollectionHandler) HandleApplyTemplateBundle(w http.ResponseWrit
seenCollections := make(map[string]struct{})
for _, libraryID := range libraryIDs {
library := librariesByID[libraryID]
collections, err := h.repo.ListByLibrary(workCtx, libraryID, catalog.ListLibraryCollectionsOptions{IncludeHidden: true})
if err != nil {
resp.DeleteFailed = append(resp.DeleteFailed, templateBundleCollectionEntry{
LibraryID: library.ID,
LibraryName: library.Name,
Reason: err.Error(),
})
continue
}
for _, collection := range collections {
for _, collection := range collectionsByLibraryID[libraryID] {
if _, ok := seenCollections[collection.ID]; ok {
continue
}
@@ -1549,9 +1621,15 @@ func (h *LibraryCollectionHandler) HandleApplyTemplateBundle(w http.ResponseWrit
}
if err := h.deleteServerCollection(workCtx, collection.ID); err != nil {
entry.Reason = err.Error()
resp.DeleteFailed = append(resp.DeleteFailed, entry)
if errors.Is(err, errLibraryCollectionInUse) {
entry.Reason = "in_use_by_section"
resp.DeleteSkipped = append(resp.DeleteSkipped, entry)
} else {
resp.DeleteFailed = append(resp.DeleteFailed, entry)
}
continue
}
forgetTemplateBundleExistingCollection(remainingByLibrarySlug, selectedLibraryIDs, collection)
entry.Reason = "deleted"
resp.Deleted = append(resp.Deleted, entry)
}
@@ -1577,6 +1655,9 @@ func (h *LibraryCollectionHandler) HandleApplyTemplateBundle(w http.ResponseWrit
key := templateBundleManagementKey(bundle.ID, templateID, library.ID)
existing, err := h.repo.GetByManagementKey(workCtx, collectionManagementModeTemplateBundle, bundle.ID, key)
if err == nil {
if !req.DryRun {
h.ensureTemplatePoster(workCtx, existing, tmpl)
}
entry.CollectionID = existing.ID
entry.Reason = "already_exists"
resp.Skipped = append(resp.Skipped, entry)
@@ -1592,6 +1673,26 @@ func (h *LibraryCollectionHandler) HandleApplyTemplateBundle(w http.ResponseWrit
resp.Failed = append(resp.Failed, entry)
continue
}
if !(req.DryRun && req.DeleteExisting) {
existingBySlug := remainingByLibrarySlug[templateBundleExistingCollectionKey{
LibraryID: library.ID,
Slug: slugifyCollectionName(tmpl.Title),
}]
if existingBySlug != nil {
if !req.DryRun {
h.ensureTemplatePoster(workCtx, existingBySlug, tmpl)
}
entry.CollectionID = existingBySlug.ID
entry.Reason = "already_exists"
resp.Skipped = append(resp.Skipped, entry)
collectionRefs[templateBundleCollectionRefKey{LibraryID: library.ID, TemplateID: tmpl.ID}] = templateBundleCollectionRef{
CollectionID: existingBySlug.ID,
Template: tmpl,
Library: library,
}
continue
}
}
if req.DryRun {
entry.Reason = "would_create"
resp.Created = append(resp.Created, entry)
@@ -1611,15 +1712,28 @@ func (h *LibraryCollectionHandler) HandleApplyTemplateBundle(w http.ResponseWrit
entry.CollectionID = collection.ID
pendingSyncs = append(pendingSyncs, pendingTemplateBundleSync{
CollectionID: collection.ID,
SyncSchedule: collection.SyncSchedule,
Entry: entry,
})
}
}
if len(pendingSyncs) > 0 {
created, failed := h.syncTemplateBundleCollections(workCtx, pendingSyncs)
resp.Created = append(resp.Created, created...)
resp.Failed = append(resp.Failed, failed...)
var created []templateBundleApplyEntry
if shouldQueueTemplateBundleSyncs(bundle, len(pendingSyncs)) {
created = templateBundleCreatedEntries(pendingSyncs)
queued, failed := h.queueTemplateBundleSyncs(workCtx, 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
created, failed = h.syncTemplateBundleCollections(workCtx, pendingSyncs)
resp.Created = append(resp.Created, created...)
resp.Failed = append(resp.Failed, failed...)
h.generateTemplateBundleCollagesAsync(created)
}
for _, entry := range created {
library := librariesByID[entry.LibraryID]
tmpl, _ := h.templateRegistry().Get(entry.TemplateID)
@@ -1629,7 +1743,6 @@ func (h *LibraryCollectionHandler) HandleApplyTemplateBundle(w http.ResponseWrit
Library: library,
}
}
h.generateTemplateBundleCollagesAsync(created)
}
if req.Featured != nil {
@@ -1648,6 +1761,127 @@ func (h *LibraryCollectionHandler) HandleApplyTemplateBundle(w http.ResponseWrit
writeJSON(w, http.StatusOK, resp)
}
func (h *LibraryCollectionHandler) ensureTemplatePoster(
ctx context.Context,
collection *models.LibraryCollection,
tmpl templates.Template,
) {
posterPath := strings.TrimSpace(tmpl.PosterPath)
if posterPath == "" || collection == nil {
return
}
if collection.PosterURL != "" && !collection.PosterAutoGenerated {
return
}
emptyThumbhash := ""
notAutoGenerated := false
if err := h.repo.Update(ctx, catalog.UpdateLibraryCollectionInput{
ID: collection.ID,
PosterURL: &posterPath,
PosterThumbhash: &emptyThumbhash,
PosterAutoGenerated: &notAutoGenerated,
}); err != nil {
slog.Warn("failed to apply template poster",
"collection_id", collection.ID,
"template_id", tmpl.ID,
"poster_path", posterPath,
"error", err,
)
}
}
func shouldQueueTemplateBundleSyncs(bundle templates.Bundle, pendingCount int) bool {
return bundle.ID == "all_defaults" || pendingCount > templateBundleInlineSyncLimit
}
func templateBundleCreatedEntries(pending []pendingTemplateBundleSync) []templateBundleApplyEntry {
entries := make([]templateBundleApplyEntry, 0, len(pending))
for _, item := range pending {
entries = append(entries, item.Entry)
}
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)
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)
}
return queued, failed
}
func (h *LibraryCollectionHandler) syncTemplateBundleCollectionsAsync(
bundleID string,
pending []pendingTemplateBundleSync,
) {
if h.service == nil || len(pending) == 0 {
return
}
pending = append([]pendingTemplateBundleSync(nil), pending...)
go func() {
ctx, cancel := context.WithTimeout(context.Background(), templateBundleAsyncSyncTimeout)
defer cancel()
startedAt := time.Now()
created, failed := h.syncTemplateBundleCollections(ctx, pending)
h.generateTemplateBundleCollagesAsync(created)
h.advanceTemplateBundleSyncSchedules(ctx, pending)
slog.Info("template bundle async sync complete",
"bundle_id", bundleID,
"pending", len(pending),
"synced", len(created),
"failed", len(failed),
"duration", time.Since(startedAt).Round(time.Millisecond),
)
for _, entry := range failed {
slog.Warn("template bundle async sync failed",
"bundle_id", bundleID,
"collection_id", entry.CollectionID,
"template_id", entry.TemplateID,
"library_id", entry.LibraryID,
"reason", entry.Reason,
)
}
}()
}
func (h *LibraryCollectionHandler) advanceTemplateBundleSyncSchedules(
ctx context.Context,
pending []pendingTemplateBundleSync,
) {
now := time.Now()
for _, item := range pending {
if item.SyncSchedule == nil || strings.TrimSpace(*item.SyncSchedule) == "" {
continue
}
next := catalog.ComputeNextSyncAtFrom(*item.SyncSchedule, now)
if err := h.repo.UpdateNextSyncAt(ctx, item.CollectionID, next); err != nil {
slog.Warn("template bundle async sync: failed to advance schedule",
"collection_id", item.CollectionID,
"template_id", item.Entry.TemplateID,
"error", err,
)
}
}
}
func (h *LibraryCollectionHandler) syncTemplateBundleCollections(
ctx context.Context,
pending []pendingTemplateBundleSync,
@@ -1707,6 +1941,14 @@ func (h *LibraryCollectionHandler) generateTemplateBundleCollagesAsync(entries [
for _, collectionID := range collectionIDs {
collectionID := collectionID
eg.Go(func() error {
collection, err := h.repo.GetByID(ctx, collectionID)
if err != nil {
slog.Warn("collage: failed to load collection", "collection_id", collectionID, "error", err)
return nil
}
if collection.PosterURL != "" && !collection.PosterAutoGenerated {
return nil
}
if err := h.service.CollageGen.GenerateCollectionPoster(ctx, collectionID); err != nil {
if errors.Is(err, collage.ErrNotEnoughImages) {
slog.Debug("collage: not enough images", "collection_id", collectionID)
@@ -1944,6 +2186,7 @@ func (h *LibraryCollectionHandler) createCollectionFromTemplate(
Limit: limit,
Featured: tmpl.Featured,
SortOrder: tmpl.DefaultSortOrder,
PosterURL: tmpl.PosterPath,
SyncSchedule: tmpl.DefaultSyncSchedule,
ManagementMode: collectionManagementModeTemplateBundle,
ManagementSource: bundleID,
@@ -1961,6 +2204,7 @@ func (h *LibraryCollectionHandler) createCollectionFromTemplate(
Limit: limit,
Featured: tmpl.Featured,
SortOrder: tmpl.DefaultSortOrder,
PosterURL: tmpl.PosterPath,
SyncSchedule: tmpl.DefaultSyncSchedule,
ManagementMode: collectionManagementModeTemplateBundle,
ManagementSource: bundleID,
@@ -1978,6 +2222,7 @@ func (h *LibraryCollectionHandler) createCollectionFromTemplate(
Limit: limit,
Featured: tmpl.Featured,
SortOrder: tmpl.DefaultSortOrder,
PosterURL: tmpl.PosterPath,
SyncSchedule: tmpl.DefaultSyncSchedule,
ManagementMode: collectionManagementModeTemplateBundle,
ManagementSource: bundleID,
@@ -2009,6 +2254,7 @@ func (h *LibraryCollectionHandler) createCollectionFromTemplate(
Limit: limit,
Featured: tmpl.Featured,
SortOrder: tmpl.DefaultSortOrder,
PosterURL: tmpl.PosterPath,
SyncSchedule: tmpl.DefaultSyncSchedule,
ManagementMode: collectionManagementModeTemplateBundle,
ManagementSource: bundleID,
@@ -2050,6 +2296,7 @@ func (h *LibraryCollectionHandler) createMDBListCollection(
Visibility: "visible",
Featured: req.Featured,
SortOrder: req.SortOrder,
PosterURL: req.PosterURL,
SourceURL: req.URL,
SourceConfig: sourceConfig,
ManagementMode: managementMode,
@@ -2098,6 +2345,7 @@ func (h *LibraryCollectionHandler) createTMDBCollection(
Visibility: "visible",
Featured: req.Featured,
SortOrder: req.SortOrder,
PosterURL: req.PosterURL,
SourceURL: buildTMDBSourceURL(preset, mediaType, timeWindow),
SourceConfig: sourceConfig,
ManagementMode: managementMode,
@@ -2155,6 +2403,7 @@ func (h *LibraryCollectionHandler) createTMDBFranchiseCollection(
Visibility: "visible",
Featured: req.Featured,
SortOrder: req.SortOrder,
PosterURL: req.PosterURL,
SourceURL: buildTMDBCollectionSourceURL(req.CollectionID),
SourceConfig: sourceConfig,
ManagementMode: managementMode,
@@ -2206,6 +2455,7 @@ func (h *LibraryCollectionHandler) createTMDBDiscoverCollection(
Visibility: "visible",
Featured: req.Featured,
SortOrder: req.SortOrder,
PosterURL: req.PosterURL,
SourceURL: buildTMDBDiscoverSourceURL(req.MediaType, req.Spec.SortBy),
SourceConfig: sourceConfig,
ManagementMode: managementMode,
@@ -2394,6 +2644,7 @@ func (h *LibraryCollectionHandler) HandleImportTraktCollection(w http.ResponseWr
CollectionType: "trakt",
Visibility: "visible",
Featured: req.Featured,
PosterURL: req.PosterURL,
SourceURL: buildTraktSourceURL(preset, mediaType, profileID),
SourceConfig: sourceConfig,
ManagementMode: managementMode,
@@ -2592,6 +2843,9 @@ func (h *LibraryCollectionHandler) presignGPURL(r *http.Request, path string) st
if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") {
return path
}
if strings.HasPrefix(path, "/") {
return path
}
if h.s3GP == nil {
return ""
}
@@ -57,6 +57,7 @@ type userImportSharedFields struct {
Limit *int `json:"limit,omitempty"`
SyncSchedule string `json:"sync_schedule"`
IsShared bool `json:"is_shared"`
PosterURL string `json:"poster_url"`
LibraryIDs []int `json:"library_ids,omitempty"`
}
@@ -205,6 +206,7 @@ func (h *UserCollectionImportHandler) createImportedCollection(
SourceConfig: sourceConfigJSON,
SyncSchedule: schedule,
NextSyncAt: usercollections.InitialNextSyncAt(schedule),
PosterURL: strings.TrimSpace(shared.PosterURL),
})
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to create collection")
+49 -50
View File
@@ -12,6 +12,7 @@ import (
"time"
"github.com/Silo-Server/silo-server/internal/collage"
"github.com/Silo-Server/silo-server/internal/collectionutil"
"github.com/Silo-Server/silo-server/internal/models"
)
@@ -245,7 +246,6 @@ func (s *LibraryCollectionService) syncMDBListCollection(ctx context.Context, co
if err != nil {
return nil, err
}
entries = limitMDBListEntries(entries, limit)
// 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
@@ -308,7 +308,6 @@ func (s *LibraryCollectionService) syncMDBListCollection(ctx context.Context, co
}
candidates := pickCandidatesByPriority(lookup, entry, itemType)
if len(candidates) == 0 {
warnings = append(warnings, fmt.Sprintf("No match in library %d for %s", collection.LibraryID, entry.Title))
continue
}
sourceRank := entry.Rank
@@ -332,7 +331,19 @@ func (s *LibraryCollectionService) syncMDBListCollection(ctx context.Context, co
return nil, err
}
resolvedByIndex := make(map[int]resolvedEntry, len(resolved))
for _, r := range resolved {
resolvedByIndex[r.entryIndex] = r
}
scannedEntries := 0
for index, entry := range entries {
scannedEntries = index + 1
r, ok := resolvedByIndex[index]
if !ok {
warnings = append(warnings, fmt.Sprintf("No match in library %d for %s", collection.LibraryID, entry.Title))
continue
}
var chosen string
for _, candidate := range r.candidates {
if libraryMembers[candidate] {
@@ -341,7 +352,7 @@ func (s *LibraryCollectionService) syncMDBListCollection(ctx context.Context, co
}
}
if chosen == "" {
warnings = append(warnings, fmt.Sprintf("No match in library %d for %s", collection.LibraryID, entries[r.entryIndex].Title))
warnings = append(warnings, fmt.Sprintf("No match in library %d for %s", collection.LibraryID, entry.Title))
continue
}
matchedItems = append(matchedItems, LibraryCollectionItemInput{
@@ -349,6 +360,9 @@ func (s *LibraryCollectionService) syncMDBListCollection(ctx context.Context, co
Position: len(matchedItems),
SourceRank: r.sourceRank,
})
if collectionutil.ItemLimitReached(len(matchedItems), limit) {
break
}
}
if err := s.collections.ReplaceItems(ctx, collection.ID, matchedItems); err != nil {
@@ -368,11 +382,11 @@ func (s *LibraryCollectionService) syncMDBListCollection(ctx context.Context, co
run, err := s.collections.RecordSyncRun(ctx, RecordLibraryCollectionSyncRunInput{
CollectionID: collection.ID,
Status: status,
Message: fmt.Sprintf("Matched %d of %d entries", len(matchedItems), len(entries)),
Message: fmt.Sprintf("Matched %d of %d entries", len(matchedItems), scannedEntries),
ItemsAdded: len(matchedItems),
ItemsRemoved: 0,
ItemsMatched: len(matchedItems),
ItemsUnmatched: len(entries) - len(matchedItems),
ItemsUnmatched: scannedEntries - len(matchedItems),
Warnings: warningsJSON,
StartedAt: startedAt,
CompletedAt: completedAt,
@@ -388,13 +402,6 @@ func (s *LibraryCollectionService) syncMDBListCollection(ctx context.Context, co
return run, nil
}
func limitMDBListEntries(entries []mdblistEntry, limit *int) []mdblistEntry {
if limit == nil || *limit <= 0 || len(entries) <= *limit {
return entries
}
return entries[:*limit]
}
func (s *LibraryCollectionService) syncTMDBPresetCollection(ctx context.Context, collection *models.LibraryCollection, cfg libraryCollectionSourceConfig, opts SyncCollectionOptions) (*models.LibraryCollectionSyncRun, error) {
startedAt := syncTimestamp()
@@ -419,19 +426,12 @@ func (s *LibraryCollectionService) syncTMDBPresetCollection(ctx context.Context,
}
}
fetchLimit := 0
if cfg.Limit != nil {
fetchLimit = *cfg.Limit
}
fetchLimit := collectionutil.SourceFetchLimit(cfg.Limit)
results, err := s.TMDBCollections.GetCollectionPreset(ctx, preset, mediaType, timeWindow, fetchLimit)
if err != nil {
return nil, fmt.Errorf("fetching TMDB preset: %w", err)
}
if cfg.Limit != nil && *cfg.Limit > 0 && len(results) > *cfg.Limit {
results = results[:*cfg.Limit]
}
slog.Info("TMDB preset sync: fetched results",
"collection_id", collection.ID,
"preset", preset,
@@ -445,8 +445,10 @@ func (s *LibraryCollectionService) syncTMDBPresetCollection(ctx context.Context,
warnings := make([]string, 0)
unmatchedCount := 0
duplicateCount := 0
scannedEntries := 0
for i, entry := range results {
scannedEntries = i + 1
item, err := s.resolveTMDBEntry(ctx, collection.LibraryID, entry)
if err != nil {
return nil, err
@@ -495,6 +497,9 @@ func (s *LibraryCollectionService) syncTMDBPresetCollection(ctx context.Context,
Position: len(matchedItems),
SourceRank: i + 1,
})
if collectionutil.ItemLimitReached(len(matchedItems), cfg.Limit) {
break
}
}
slog.Info("TMDB preset sync: complete",
@@ -503,7 +508,7 @@ func (s *LibraryCollectionService) syncTMDBPresetCollection(ctx context.Context,
"matched", len(matchedItems),
"unmatched", unmatchedCount,
"duplicates", duplicateCount,
"total", len(results),
"total", scannedEntries,
)
if err := s.collections.ReplaceItems(ctx, collection.ID, matchedItems); err != nil {
@@ -514,7 +519,7 @@ func (s *LibraryCollectionService) syncTMDBPresetCollection(ctx context.Context,
if len(warnings) > 0 {
status = "warning"
}
message := fmt.Sprintf("Matched %d of %d entries", len(matchedItems), len(results))
message := fmt.Sprintf("Matched %d of %d entries", len(matchedItems), scannedEntries)
if duplicateCount > 0 {
message = fmt.Sprintf("%s (%d duplicates skipped)", message, duplicateCount)
}
@@ -587,14 +592,6 @@ func (s *LibraryCollectionService) syncTMDBFranchiseCollection(ctx context.Conte
return nil, fmt.Errorf("fetching TMDB collection: %w", err)
}
// `limit` is intentionally applied AFTER the fetch — TMDB collection
// payloads are tiny (~1 page per franchise) and the order is curated, so
// truncating beforehand would drop the canonical tail (e.g. the last few
// MCU entries) without ever consulting the library.
if cfg.Limit != nil && *cfg.Limit > 0 && len(results) > *cfg.Limit {
results = results[:*cfg.Limit]
}
slog.Info("TMDB franchise sync: fetched results",
"collection_id", collection.ID,
"tmdb_collection_id", cfg.CollectionID,
@@ -606,8 +603,10 @@ func (s *LibraryCollectionService) syncTMDBFranchiseCollection(ctx context.Conte
warnings := make([]string, 0)
unmatchedCount := 0
duplicateCount := 0
scannedEntries := 0
for i, entry := range results {
scannedEntries = i + 1
item, err := s.resolveTMDBEntry(ctx, collection.LibraryID, entry)
if err != nil {
return nil, err
@@ -647,6 +646,9 @@ func (s *LibraryCollectionService) syncTMDBFranchiseCollection(ctx context.Conte
Position: len(matchedItems),
SourceRank: i + 1,
})
if collectionutil.ItemLimitReached(len(matchedItems), cfg.Limit) {
break
}
}
slog.Info("TMDB franchise sync: complete",
@@ -655,7 +657,7 @@ func (s *LibraryCollectionService) syncTMDBFranchiseCollection(ctx context.Conte
"matched", len(matchedItems),
"unmatched", unmatchedCount,
"duplicates", duplicateCount,
"total", len(results),
"total", scannedEntries,
)
if err := s.collections.ReplaceItems(ctx, collection.ID, matchedItems); err != nil {
@@ -666,7 +668,7 @@ func (s *LibraryCollectionService) syncTMDBFranchiseCollection(ctx context.Conte
if len(warnings) > 0 {
status = "warning"
}
message := fmt.Sprintf("Matched %d of %d entries", len(matchedItems), len(results))
message := fmt.Sprintf("Matched %d of %d entries", len(matchedItems), scannedEntries)
if duplicateCount > 0 {
message = fmt.Sprintf("%s (%d duplicates skipped)", message, duplicateCount)
}
@@ -752,19 +754,12 @@ func (s *LibraryCollectionService) syncTMDBDiscoverCollection(ctx context.Contex
OriginalLanguage: cfg.Discover.OriginalLanguage,
}
fetchLimit := 0
if cfg.Limit != nil {
fetchLimit = *cfg.Limit
}
fetchLimit := collectionutil.SourceFetchLimit(cfg.Limit)
results, err := s.TMDBDiscovers.Discover(ctx, mediaType, params, fetchLimit)
if err != nil {
return nil, fmt.Errorf("fetching TMDB discover: %w", err)
}
if cfg.Limit != nil && *cfg.Limit > 0 && len(results) > *cfg.Limit {
results = results[:*cfg.Limit]
}
slog.Info("TMDB discover sync: fetched results",
"collection_id", collection.ID,
"media_type", mediaType,
@@ -777,8 +772,10 @@ func (s *LibraryCollectionService) syncTMDBDiscoverCollection(ctx context.Contex
warnings := make([]string, 0)
unmatchedCount := 0
duplicateCount := 0
scannedEntries := 0
for i, entry := range results {
scannedEntries = i + 1
item, err := s.resolveTMDBEntry(ctx, collection.LibraryID, entry)
if err != nil {
return nil, err
@@ -814,6 +811,9 @@ func (s *LibraryCollectionService) syncTMDBDiscoverCollection(ctx context.Contex
Position: len(matchedItems),
SourceRank: i + 1,
})
if collectionutil.ItemLimitReached(len(matchedItems), cfg.Limit) {
break
}
}
slog.Info("TMDB discover sync: complete",
@@ -822,7 +822,7 @@ func (s *LibraryCollectionService) syncTMDBDiscoverCollection(ctx context.Contex
"matched", len(matchedItems),
"unmatched", unmatchedCount,
"duplicates", duplicateCount,
"total", len(results),
"total", scannedEntries,
)
if err := s.collections.ReplaceItems(ctx, collection.ID, matchedItems); err != nil {
@@ -833,7 +833,7 @@ func (s *LibraryCollectionService) syncTMDBDiscoverCollection(ctx context.Contex
if len(warnings) > 0 {
status = "warning"
}
message := fmt.Sprintf("Matched %d of %d entries", len(matchedItems), len(results))
message := fmt.Sprintf("Matched %d of %d entries", len(matchedItems), scannedEntries)
if duplicateCount > 0 {
message = fmt.Sprintf("%s (%d duplicates skipped)", message, duplicateCount)
}
@@ -910,17 +910,11 @@ func (s *LibraryCollectionService) syncTraktPresetCollection(ctx context.Context
accessToken = token
}
fetchLimit := 0
if cfg.Limit != nil {
fetchLimit = *cfg.Limit
}
fetchLimit := collectionutil.SourceFetchLimit(cfg.Limit)
results, err := s.TraktCollections.GetCollectionPreset(ctx, preset, mediaType, fetchLimit, accessToken)
if err != nil {
return nil, fmt.Errorf("fetching Trakt preset: %w", err)
}
if cfg.Limit != nil && *cfg.Limit > 0 && len(results) > *cfg.Limit {
results = results[:*cfg.Limit]
}
slog.Info("Trakt preset sync: fetched results",
"collection_id", collection.ID,
@@ -934,8 +928,10 @@ func (s *LibraryCollectionService) syncTraktPresetCollection(ctx context.Context
warnings := make([]string, 0)
unmatchedCount := 0
duplicateCount := 0
scannedEntries := 0
for i, entry := range results {
scannedEntries = i + 1
item, err := s.resolveTraktEntry(ctx, collection.LibraryID, entry)
if err != nil {
return nil, err
@@ -966,6 +962,9 @@ func (s *LibraryCollectionService) syncTraktPresetCollection(ctx context.Context
Position: len(matchedItems),
SourceRank: sourceRank,
})
if collectionutil.ItemLimitReached(len(matchedItems), cfg.Limit) {
break
}
}
if err := s.collections.ReplaceItems(ctx, collection.ID, matchedItems); err != nil {
@@ -976,7 +975,7 @@ func (s *LibraryCollectionService) syncTraktPresetCollection(ctx context.Context
if len(warnings) > 0 {
status = "warning"
}
message := fmt.Sprintf("Matched %d of %d entries", len(matchedItems), len(results))
message := fmt.Sprintf("Matched %d of %d entries", len(matchedItems), scannedEntries)
if duplicateCount > 0 {
message = fmt.Sprintf("%s (%d duplicates skipped)", message, duplicateCount)
}
+151 -1
View File
@@ -1688,7 +1688,7 @@ var builtinTemplates = []Template{
},
}
var builtinBundles = []Bundle{
var builtinBundles = withAllDefaultsBundle([]Bundle{
{
ID: "core_defaults",
Title: "Core Defaults",
@@ -1711,6 +1711,156 @@ var builtinBundles = []Bundle{
"mdblist_imdb_top_250_shows",
},
},
{
ID: "streaming_expanded",
Title: "Streaming Originals",
Description: "Provider-specific originals and specialty streaming shelves.",
TemplateIDs: []string{
"mdblist_streaming_apple_tv_plus",
"mdblist_streaming_disney_plus_originals",
"mdblist_streaming_hbo_max_originals",
"mdblist_streaming_hulu_originals",
"mdblist_streaming_netflix_originals",
"mdblist_streaming_peacock_originals",
"mdblist_streaming_prime_video_originals",
"mdblist_streaming_shudder",
},
},
{
ID: "awards_and_years",
Title: "Awards & Yearly Picks",
Description: "Oscar, Golden Globe, yearly best-of, and movie chart collections.",
TemplateIDs: []string{
"mdblist_awards_oscar_winners",
"mdblist_awards_golden_globes_winners",
"mdblist_best_of_2023",
"mdblist_best_of_2024",
"mdblist_best_of_2025",
"mdblist_charts_popular_movies",
},
},
{
ID: "seasonal_collections",
Title: "Seasonal Collections",
Description: "Holiday, observance, and calendar-driven collection shelves.",
TemplateIDs: []string{
"mdblist_seasonal_halloween",
"mdblist_seasonal_christmas",
"mdblist_seasonal_valentines_day",
"mdblist_seasonal_easter",
"mdblist_seasonal_thanksgiving",
"mdblist_seasonal_new_year",
"mdblist_seasonal_aapi_heritage_month",
"mdblist_seasonal_latinx_heritage_month",
"mdblist_seasonal_pride_month",
},
},
{
ID: "studio_collections",
Title: "Studios & Labels",
Description: "Curated studio, label, and collection-brand shelves.",
TemplateIDs: []string{
"mdblist_misc_criterion_collection",
"mdblist_misc_a24",
"mdblist_misc_ifc_films",
"mdblist_misc_studio_ghibli",
},
},
{
ID: "popular_genres",
Title: "Popular Genres",
Description: "TMDB Discover genre shelves sorted by current popularity.",
TemplateIDs: []string{
"tmdb_discover_popular_action",
"tmdb_discover_popular_adventure",
"tmdb_discover_popular_animation",
"tmdb_discover_popular_comedy",
"tmdb_discover_popular_crime",
"tmdb_discover_popular_documentary",
"tmdb_discover_popular_drama",
"tmdb_discover_popular_family",
"tmdb_discover_popular_fantasy",
"tmdb_discover_popular_history",
"tmdb_discover_popular_horror",
"tmdb_discover_popular_music",
"tmdb_discover_popular_mystery",
"tmdb_discover_popular_romance",
"tmdb_discover_popular_science_fiction",
"tmdb_discover_popular_thriller",
"tmdb_discover_popular_war",
"tmdb_discover_popular_western",
"tmdb_discover_kids_movies",
},
},
{
ID: "top_rated_genres",
Title: "Top Rated Genres",
Description: "TMDB Discover genre shelves sorted by vote average with vote floors.",
TemplateIDs: []string{
"tmdb_discover_top_rated_action",
"tmdb_discover_top_rated_adventure",
"tmdb_discover_top_rated_animation",
"tmdb_discover_top_rated_comedy",
"tmdb_discover_top_rated_crime",
"tmdb_discover_top_rated_documentary",
"tmdb_discover_top_rated_drama",
"tmdb_discover_top_rated_family",
"tmdb_discover_top_rated_fantasy",
"tmdb_discover_top_rated_history",
"tmdb_discover_top_rated_horror",
"tmdb_discover_top_rated_music",
"tmdb_discover_top_rated_mystery",
"tmdb_discover_top_rated_romance",
"tmdb_discover_top_rated_science_fiction",
"tmdb_discover_top_rated_thriller",
"tmdb_discover_top_rated_war",
"tmdb_discover_top_rated_western",
},
},
{
ID: "franchise_collections",
Title: "Franchise Collections",
Description: "TMDB franchise and saga collections for major movie series.",
TemplateIDs: []string{
"tmdb_franchise_star_wars",
"tmdb_franchise_james_bond",
"tmdb_franchise_wizarding_world",
"tmdb_franchise_fast_furious",
"tmdb_franchise_lord_of_the_rings",
"tmdb_franchise_hobbit",
"tmdb_franchise_jurassic_park",
"tmdb_franchise_pirates_of_the_caribbean",
"tmdb_franchise_mission_impossible",
"tmdb_franchise_monsterverse",
"tmdb_franchise_placeholder",
},
},
})
func withAllDefaultsBundle(bundles []Bundle) []Bundle {
seen := make(map[string]struct{})
allTemplateIDs := make([]string, 0)
for _, bundle := range bundles {
for _, id := range bundle.TemplateIDs {
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
allTemplateIDs = append(allTemplateIDs, id)
}
}
allDefaults := Bundle{
ID: "all_defaults",
Title: "All Defaults",
Description: "Every default bulk-add template, including core, streaming, awards, seasonal, genre, and franchise collections.",
TemplateIDs: allTemplateIDs,
}
out := make([]Bundle, 0, len(bundles)+1)
out = append(out, allDefaults)
out = append(out, bundles...)
return out
}
func init() {
@@ -1,6 +1,8 @@
package templates
import (
"os"
"path/filepath"
"strings"
"testing"
)
@@ -26,11 +28,30 @@ func TestBuiltinCatalog(t *testing.T) {
if strings.TrimSpace(tmpl.PosterPath) == "" {
t.Errorf("template %q has empty poster path", tmpl.ID)
}
if tmpl.PosterPath != "" && !strings.HasPrefix(tmpl.PosterPath, "/images/collection-templates/") {
t.Errorf("template %q has invalid poster path %q", tmpl.ID, tmpl.PosterPath)
}
seenIDs[tmpl.ID] = true
}
}
}
func TestBuiltinTemplatePosterAssetsExist(t *testing.T) {
assetRoot := filepath.Join("..", "..", "..", "web", "public", "images", "collection-templates")
rawRoot := filepath.Join(assetRoot, "raw")
for _, tmpl := range List() {
t.Run(tmpl.ID, func(t *testing.T) {
if _, err := os.Stat(filepath.Join(assetRoot, tmpl.ID+".jpg")); err != nil {
t.Fatalf("final poster asset missing: %v", err)
}
if _, err := os.Stat(filepath.Join(rawRoot, tmpl.ID+".png")); err != nil {
t.Fatalf("raw poster plate missing: %v", err)
}
})
}
}
func TestBuiltinTemplatesValidate(t *testing.T) {
for _, tmpl := range List() {
t.Run(tmpl.ID, func(t *testing.T) {
@@ -88,6 +109,54 @@ func TestCoreDefaultsBundleReferencesValidProfileFreeTemplates(t *testing.T) {
}
}
func TestBundleOnlyTemplatesAreReachableFromBundles(t *testing.T) {
bundled := make(map[string]bool)
for _, bundle := range ListBundles() {
for _, id := range bundle.TemplateIDs {
bundled[id] = true
}
}
for _, tmpl := range List() {
switch tmpl.Source {
case SourceTMDBDiscover, SourceTMDBCollection:
if !bundled[tmpl.ID] {
t.Errorf("bundle-only template %q is not referenced by any bundle", tmpl.ID)
}
}
}
}
func TestAllDefaultsBundleIncludesEveryOtherDefaultBundle(t *testing.T) {
allDefaults, ok := GetBundle("all_defaults")
if !ok {
t.Fatal("all_defaults bundle is not registered")
}
bundles := ListBundles()
if len(bundles) == 0 || bundles[0].ID != allDefaults.ID {
t.Fatal("all_defaults should be the first displayed bundle")
}
seen := make(map[string]struct{}, len(allDefaults.TemplateIDs))
for _, id := range allDefaults.TemplateIDs {
if _, exists := seen[id]; exists {
t.Fatalf("all_defaults contains duplicate template %q", id)
}
seen[id] = struct{}{}
}
for _, bundle := range bundles {
if bundle.ID == allDefaults.ID {
continue
}
for _, id := range bundle.TemplateIDs {
if _, ok := seen[id]; !ok {
t.Fatalf("all_defaults is missing %q from bundle %q", id, bundle.ID)
}
}
}
}
func TestRegisterBundleRejectsProfileRequiredTemplates(t *testing.T) {
r := NewRegistry()
r.Register(Template{
+24
View File
@@ -7,6 +7,30 @@ import (
var ErrOrderedIDsMismatch = errors.New("ordered_ids does not match the current set")
const (
collectionSourceFetchMultiplier = 4
collectionSourceFetchMin = 100
collectionSourceFetchMax = 500
)
func SourceFetchLimit(itemLimit *int) int {
if itemLimit == nil || *itemLimit <= 0 {
return 0
}
limit := *itemLimit * collectionSourceFetchMultiplier
if limit < collectionSourceFetchMin {
limit = collectionSourceFetchMin
}
if limit > collectionSourceFetchMax {
limit = collectionSourceFetchMax
}
return limit
}
func ItemLimitReached(itemCount int, itemLimit *int) bool {
return itemLimit != nil && *itemLimit > 0 && itemCount >= *itemLimit
}
func HasDuplicateOrderedIDs(ids []string) bool {
seen := make(map[string]struct{}, len(ids))
for _, id := range ids {
+1 -1
View File
@@ -19,7 +19,7 @@ const (
defaultAPIKey = "4ef0d7355d9ffb5151e987764708ce96"
maxRetries = 3
maxResponseBody = 1 << 20 // 1 MB
maxCollectionPresetResults = 100
maxCollectionPresetResults = 500
)
// Client is an HTTP client for the TMDB collection preset API surface.
+1 -1
View File
@@ -18,7 +18,7 @@ const (
defaultBaseURL = "https://api.trakt.tv"
maxRetries = 3
maxResponseBody = 2 << 20
maxCollectionPresetResults = 100
maxCollectionPresetResults = 500
defaultCollectionPageLimit = 20
defaultCollectionRateLimit = 5
traktAPIVersion = "2"
+5 -6
View File
@@ -199,8 +199,8 @@ 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.
func (r *Repository) DeleteGeneratedTemplateBundleFeaturedSections(ctx context.Context, bundleID string, libraryIDs []int) error {
// blocked by stale generated section references from any previous bundle.
func (r *Repository) DeleteGeneratedTemplateBundleFeaturedSections(ctx context.Context, libraryIDs []int) error {
if len(libraryIDs) == 0 {
return nil
}
@@ -208,16 +208,15 @@ 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($3::int[]))
(scope = 'library' AND library_id = ANY($2::int[]))
OR (
scope = 'home'
AND config->>'library_id' ~ '^[0-9]+$'
AND (config->>'library_id')::int = ANY($3::int[])
AND (config->>'library_id')::int = ANY($2::int[])
)
)
`, SectionCollection, bundleID, libraryIDs)
`, SectionCollection, libraryIDs)
if err != nil {
return fmt.Errorf("deleting generated template bundle featured sections: %w", err)
}
+40 -25
View File
@@ -11,6 +11,7 @@ import (
"time"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/collectionutil"
"github.com/Silo-Server/silo-server/internal/userstore"
)
@@ -122,9 +123,6 @@ func (s *Service) syncMDBList(ctx context.Context, store userstore.UserStore, co
if err != nil {
return nil, nil, err
}
if cfg.Limit != nil && *cfg.Limit > 0 && len(entries) > *cfg.Limit {
entries = entries[:*cfg.Limit]
}
var movieBatch, seriesBatch catalog.ExternalIDBatch
for _, entry := range entries {
@@ -152,7 +150,8 @@ func (s *Service) syncMDBList(ctx context.Context, store userstore.UserStore, co
return nil, nil, err
}
matched, unmatched := resolveMatched(len(entries), func(i int) string {
resolveLimit := collectionResolveLimit(cfg)
matched, unmatched, scanned := resolveMatchedWithLimit(len(entries), resolveLimit, func(i int) string {
entry := entries[i]
itemType := mdbListItemType(entry)
lookup := movieLookup
@@ -173,7 +172,8 @@ func (s *Service) syncMDBList(ctx context.Context, store userstore.UserStore, co
if err != nil {
return nil, nil, err
}
return s.applyResult(ctx, store, collection, startedAt, matched, len(entries), unmatched+droppedByLib)
matched = limitCollectionItems(matched, cfg.Limit)
return s.applyResult(ctx, store, collection, startedAt, matched, scanned, unmatched+droppedByLib)
}
func mdbListItemType(entry mdblistEntry) string {
@@ -239,11 +239,13 @@ func (s *Service) filterByLibraries(ctx context.Context, matched []userstore.Col
// resolveMatched walks `total` entries, calls `resolve` to get the candidate
// content_id for each, and produces deduped, position-numbered replacements
// plus an unmatched count. Shared by all three source backends.
func resolveMatched(total int, resolve func(i int) string) ([]userstore.CollectionItemReplacement, int) {
func resolveMatchedWithLimit(total int, limit *int, resolve func(i int) string) ([]userstore.CollectionItemReplacement, int, int) {
matched := make([]userstore.CollectionItemReplacement, 0, total)
seen := make(map[string]struct{}, total)
unmatched := 0
scanned := 0
for i := 0; i < total; i++ {
scanned = i + 1
contentID := resolve(i)
if contentID == "" {
unmatched++
@@ -257,8 +259,29 @@ func resolveMatched(total int, resolve func(i int) string) ([]userstore.Collecti
MediaItemID: contentID,
Position: len(matched),
})
if collectionutil.ItemLimitReached(len(matched), limit) {
break
}
}
return matched, unmatched
return matched, unmatched, scanned
}
func collectionResolveLimit(cfg SourceConfig) *int {
if len(cfg.LibraryIDs) > 0 {
return nil
}
return cfg.Limit
}
func limitCollectionItems(items []userstore.CollectionItemReplacement, limit *int) []userstore.CollectionItemReplacement {
if limit == nil || *limit <= 0 || len(items) <= *limit {
return items
}
items = items[:*limit]
for i := range items {
items[i].Position = i
}
return items
}
func (s *Service) fetchMDBListEntries(ctx context.Context, url string) ([]mdblistEntry, error) {
@@ -298,17 +321,11 @@ func (s *Service) syncTMDB(ctx context.Context, store userstore.UserStore, colle
if timeWindow == "" && preset == "trending" {
timeWindow = "day"
}
limit := 0
if cfg.Limit != nil {
limit = *cfg.Limit
}
limit := collectionutil.SourceFetchLimit(cfg.Limit)
results, err := s.TMDBCollections.GetCollectionPreset(ctx, preset, mediaType, timeWindow, limit)
if err != nil {
return nil, nil, fmt.Errorf("fetching TMDB preset: %w", err)
}
if cfg.Limit != nil && *cfg.Limit > 0 && len(results) > *cfg.Limit {
results = results[:*cfg.Limit]
}
// TMDB returns mixed-media-type results (the "trending all" preset can
// emit both movie and tv). Batch by item type so each gets a single
@@ -338,7 +355,8 @@ func (s *Service) syncTMDB(ctx context.Context, store userstore.UserStore, colle
return nil, nil, err
}
matched, unmatched := resolveMatched(len(results), func(i int) string {
resolveLimit := collectionResolveLimit(cfg)
matched, unmatched, scanned := resolveMatchedWithLimit(len(results), resolveLimit, func(i int) string {
entry := results[i]
itemType := "movie"
lookup := movieLookup
@@ -360,7 +378,8 @@ func (s *Service) syncTMDB(ctx context.Context, store userstore.UserStore, colle
if err != nil {
return nil, nil, err
}
return s.applyResult(ctx, store, collection, startedAt, matched, len(results), unmatched+droppedByLib)
matched = limitCollectionItems(matched, cfg.Limit)
return s.applyResult(ctx, store, collection, startedAt, matched, scanned, unmatched+droppedByLib)
}
// ── Trakt presets ────────────────────────────────────────────────────────────
@@ -394,17 +413,11 @@ func (s *Service) syncTrakt(ctx context.Context, store userstore.UserStore, coll
accessToken = token
}
limit := 0
if cfg.Limit != nil {
limit = *cfg.Limit
}
limit := collectionutil.SourceFetchLimit(cfg.Limit)
results, err := s.TraktCollections.GetCollectionPreset(ctx, preset, mediaType, limit, accessToken)
if err != nil {
return nil, nil, fmt.Errorf("fetching Trakt preset: %w", err)
}
if cfg.Limit != nil && *cfg.Limit > 0 && len(results) > *cfg.Limit {
results = results[:*cfg.Limit]
}
itemType := "movie"
if mediaType == "tv" {
@@ -427,7 +440,8 @@ func (s *Service) syncTrakt(ctx context.Context, store userstore.UserStore, coll
return nil, nil, err
}
matched, unmatched := resolveMatched(len(results), func(i int) string {
resolveLimit := collectionResolveLimit(cfg)
matched, unmatched, scanned := resolveMatchedWithLimit(len(results), resolveLimit, func(i int) string {
entry := results[i]
var tmdb, tvdb string
if entry.TMDBID > 0 {
@@ -442,7 +456,8 @@ func (s *Service) syncTrakt(ctx context.Context, store userstore.UserStore, coll
if err != nil {
return nil, nil, err
}
return s.applyResult(ctx, store, collection, startedAt, matched, len(results), unmatched+droppedByLib)
matched = limitCollectionItems(matched, cfg.Limit)
return s.applyResult(ctx, store, collection, startedAt, matched, scanned, unmatched+droppedByLib)
}
// ── Result application ───────────────────────────────────────────────────────
+3 -3
View File
@@ -69,18 +69,18 @@ func (s *PostgresUserStore) CreateCollection(ctx context.Context, input userstor
`INSERT INTO user_personal_collections (
id, user_id, profile_id, creator_profile_id, name, description, collection_type, is_shared,
query_definition, sort_config, source_url, source_config, sync_schedule, next_sync_at,
sort_order, include_in_server_collections, created_at, updated_at
sort_order, include_in_server_collections, poster_url, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14,
COALESCE((
SELECT MAX(sort_order) + 1
FROM user_personal_collections
WHERE user_id = $2 AND group_id IS NULL
), 0), $15, $16, $17)
), 0), $15, $16, $17, $18)
RETURNING sort_order`,
id, s.userID, input.CreatorProfileID, input.CreatorProfileID, input.Name, input.Description,
input.CollectionType, input.IsShared, input.QueryDefinition, input.SortConfig,
input.SourceURL, input.SourceConfig, input.SyncSchedule, input.NextSyncAt,
input.IncludeInServerCollections, now, now,
input.IncludeInServerCollections, input.PosterURL, now, now,
).Scan(&sortOrder)
if err != nil {
return nil, fmt.Errorf("creating collection: %w", err)
+1
View File
@@ -219,6 +219,7 @@ type CreateCollectionInput struct {
SyncSchedule *string
NextSyncAt *time.Time
IncludeInServerCollections bool
PosterURL string
}
type UpdateCollectionInput struct {
Binary file not shown.

After

Width:  |  Height:  |  Size: 537 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 396 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 362 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 393 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 451 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 454 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 343 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 334 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 564 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 600 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 555 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 467 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 394 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 497 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 587 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 558 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 519 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 507 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 440 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 498 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 273 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 402 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 326 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 287 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Some files were not shown because too many files have changed in this diff Show More