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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
|
||||
@@ -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: ¬AutoGenerated,
|
||||
}); 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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -219,6 +219,7 @@ type CreateCollectionInput struct {
|
||||
SyncSchedule *string
|
||||
NextSyncAt *time.Time
|
||||
IncludeInServerCollections bool
|
||||
PosterURL string
|
||||
}
|
||||
|
||||
type UpdateCollectionInput struct {
|
||||
|
||||
|
After Width: | Height: | Size: 537 KiB |
|
After Width: | Height: | Size: 396 KiB |
|
After Width: | Height: | Size: 362 KiB |
|
After Width: | Height: | Size: 393 KiB |
|
After Width: | Height: | Size: 451 KiB |
|
After Width: | Height: | Size: 454 KiB |
|
After Width: | Height: | Size: 343 KiB |
|
After Width: | Height: | Size: 334 KiB |
|
After Width: | Height: | Size: 564 KiB |
|
After Width: | Height: | Size: 600 KiB |
|
After Width: | Height: | Size: 555 KiB |
|
After Width: | Height: | Size: 467 KiB |
|
After Width: | Height: | Size: 394 KiB |
|
After Width: | Height: | Size: 497 KiB |
|
After Width: | Height: | Size: 587 KiB |
|
After Width: | Height: | Size: 558 KiB |
|
After Width: | Height: | Size: 519 KiB |
|
After Width: | Height: | Size: 507 KiB |
|
After Width: | Height: | Size: 440 KiB |
|
After Width: | Height: | Size: 210 KiB |
|
After Width: | Height: | Size: 498 KiB |
|
After Width: | Height: | Size: 273 KiB |
|
After Width: | Height: | Size: 318 KiB |
|
After Width: | Height: | Size: 264 KiB |
|
After Width: | Height: | Size: 402 KiB |
|
After Width: | Height: | Size: 326 KiB |
|
After Width: | Height: | Size: 287 KiB |
|
After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 2.5 MiB |
|
After Width: | Height: | Size: 2.6 MiB |
|
After Width: | Height: | Size: 2.4 MiB |
|
After Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 2.5 MiB |
|
After Width: | Height: | Size: 2.4 MiB |
|
After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 2.4 MiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 2.6 MiB |
|
After Width: | Height: | Size: 2.5 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 2.4 MiB |
|
After Width: | Height: | Size: 2.4 MiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 2.6 MiB |
|
After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 2.4 MiB |
|
After Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 1.9 MiB |