diff --git a/AGENTS.md b/AGENTS.md index 7cb79882..701d13d1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/docs/wiki/admin/collection-templates.md b/docs/wiki/admin/collection-templates.md index bc1bf5b1..e5980145 100644 --- a/docs/wiki/admin/collection-templates.md +++ b/docs/wiki/admin/collection-templates.md @@ -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 diff --git a/internal/api/handlers/collections.go b/internal/api/handlers/collections.go index 1259ee15..cf7d4d57 100644 --- a/internal/api/handlers/collections.go +++ b/internal/api/handlers/collections.go @@ -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 "" } diff --git a/internal/api/handlers/library_collections.go b/internal/api/handlers/library_collections.go index 5d427b01..c1c5bdc7 100644 --- a/internal/api/handlers/library_collections.go +++ b/internal/api/handlers/library_collections.go @@ -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 "" } diff --git a/internal/api/handlers/user_collection_imports.go b/internal/api/handlers/user_collection_imports.go index c8588a50..739f69b3 100644 --- a/internal/api/handlers/user_collection_imports.go +++ b/internal/api/handlers/user_collection_imports.go @@ -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") diff --git a/internal/catalog/library_collection_service.go b/internal/catalog/library_collection_service.go index cc6caac7..76fe0feb 100644 --- a/internal/catalog/library_collection_service.go +++ b/internal/catalog/library_collection_service.go @@ -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) } diff --git a/internal/collections/templates/builtin.go b/internal/collections/templates/builtin.go index 6b499569..48053221 100644 --- a/internal/collections/templates/builtin.go +++ b/internal/collections/templates/builtin.go @@ -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() { diff --git a/internal/collections/templates/templates_test.go b/internal/collections/templates/templates_test.go index 781e1a01..890102a8 100644 --- a/internal/collections/templates/templates_test.go +++ b/internal/collections/templates/templates_test.go @@ -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{ diff --git a/internal/collectionutil/collectionutil.go b/internal/collectionutil/collectionutil.go index 3cd4c9f5..998e6a1b 100644 --- a/internal/collectionutil/collectionutil.go +++ b/internal/collectionutil/collectionutil.go @@ -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 { diff --git a/internal/metadata/tmdb/client.go b/internal/metadata/tmdb/client.go index 4b5abb6a..8a871929 100644 --- a/internal/metadata/tmdb/client.go +++ b/internal/metadata/tmdb/client.go @@ -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. diff --git a/internal/metadata/trakt/client.go b/internal/metadata/trakt/client.go index bfe410cc..89f8eaa2 100644 --- a/internal/metadata/trakt/client.go +++ b/internal/metadata/trakt/client.go @@ -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" diff --git a/internal/sections/repo.go b/internal/sections/repo.go index 37e68746..84b655e0 100644 --- a/internal/sections/repo.go +++ b/internal/sections/repo.go @@ -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) } diff --git a/internal/usercollections/sync.go b/internal/usercollections/sync.go index df76fa8f..359f9460 100644 --- a/internal/usercollections/sync.go +++ b/internal/usercollections/sync.go @@ -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 ─────────────────────────────────────────────────────── diff --git a/internal/userstore/pgstore/collections.go b/internal/userstore/pgstore/collections.go index 6766c9d7..dcd088cb 100644 --- a/internal/userstore/pgstore/collections.go +++ b/internal/userstore/pgstore/collections.go @@ -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) diff --git a/internal/userstore/types.go b/internal/userstore/types.go index e6277966..8506cf1b 100644 --- a/internal/userstore/types.go +++ b/internal/userstore/types.go @@ -219,6 +219,7 @@ type CreateCollectionInput struct { SyncSchedule *string NextSyncAt *time.Time IncludeInServerCollections bool + PosterURL string } type UpdateCollectionInput struct { diff --git a/web/public/images/collection-templates/mdblist_awards_golden_globes_winners.jpg b/web/public/images/collection-templates/mdblist_awards_golden_globes_winners.jpg new file mode 100644 index 00000000..d1b3e44f Binary files /dev/null and b/web/public/images/collection-templates/mdblist_awards_golden_globes_winners.jpg differ diff --git a/web/public/images/collection-templates/mdblist_awards_oscar_winners.jpg b/web/public/images/collection-templates/mdblist_awards_oscar_winners.jpg new file mode 100644 index 00000000..dfe69781 Binary files /dev/null and b/web/public/images/collection-templates/mdblist_awards_oscar_winners.jpg differ diff --git a/web/public/images/collection-templates/mdblist_best_of_2023.jpg b/web/public/images/collection-templates/mdblist_best_of_2023.jpg new file mode 100644 index 00000000..4656c93c Binary files /dev/null and b/web/public/images/collection-templates/mdblist_best_of_2023.jpg differ diff --git a/web/public/images/collection-templates/mdblist_best_of_2024.jpg b/web/public/images/collection-templates/mdblist_best_of_2024.jpg new file mode 100644 index 00000000..046f032e Binary files /dev/null and b/web/public/images/collection-templates/mdblist_best_of_2024.jpg differ diff --git a/web/public/images/collection-templates/mdblist_best_of_2025.jpg b/web/public/images/collection-templates/mdblist_best_of_2025.jpg new file mode 100644 index 00000000..cf8f9c65 Binary files /dev/null and b/web/public/images/collection-templates/mdblist_best_of_2025.jpg differ diff --git a/web/public/images/collection-templates/mdblist_charts_popular_movies.jpg b/web/public/images/collection-templates/mdblist_charts_popular_movies.jpg new file mode 100644 index 00000000..c724855d Binary files /dev/null and b/web/public/images/collection-templates/mdblist_charts_popular_movies.jpg differ diff --git a/web/public/images/collection-templates/mdblist_misc_a24.jpg b/web/public/images/collection-templates/mdblist_misc_a24.jpg new file mode 100644 index 00000000..9641ed77 Binary files /dev/null and b/web/public/images/collection-templates/mdblist_misc_a24.jpg differ diff --git a/web/public/images/collection-templates/mdblist_misc_criterion_collection.jpg b/web/public/images/collection-templates/mdblist_misc_criterion_collection.jpg new file mode 100644 index 00000000..63b2bb88 Binary files /dev/null and b/web/public/images/collection-templates/mdblist_misc_criterion_collection.jpg differ diff --git a/web/public/images/collection-templates/mdblist_misc_ifc_films.jpg b/web/public/images/collection-templates/mdblist_misc_ifc_films.jpg new file mode 100644 index 00000000..055009b9 Binary files /dev/null and b/web/public/images/collection-templates/mdblist_misc_ifc_films.jpg differ diff --git a/web/public/images/collection-templates/mdblist_misc_studio_ghibli.jpg b/web/public/images/collection-templates/mdblist_misc_studio_ghibli.jpg new file mode 100644 index 00000000..d713e8a2 Binary files /dev/null and b/web/public/images/collection-templates/mdblist_misc_studio_ghibli.jpg differ diff --git a/web/public/images/collection-templates/mdblist_seasonal_aapi_heritage_month.jpg b/web/public/images/collection-templates/mdblist_seasonal_aapi_heritage_month.jpg new file mode 100644 index 00000000..48b28407 Binary files /dev/null and b/web/public/images/collection-templates/mdblist_seasonal_aapi_heritage_month.jpg differ diff --git a/web/public/images/collection-templates/mdblist_seasonal_christmas.jpg b/web/public/images/collection-templates/mdblist_seasonal_christmas.jpg new file mode 100644 index 00000000..ab24a3b5 Binary files /dev/null and b/web/public/images/collection-templates/mdblist_seasonal_christmas.jpg differ diff --git a/web/public/images/collection-templates/mdblist_seasonal_easter.jpg b/web/public/images/collection-templates/mdblist_seasonal_easter.jpg new file mode 100644 index 00000000..a0270b7a Binary files /dev/null and b/web/public/images/collection-templates/mdblist_seasonal_easter.jpg differ diff --git a/web/public/images/collection-templates/mdblist_seasonal_halloween.jpg b/web/public/images/collection-templates/mdblist_seasonal_halloween.jpg new file mode 100644 index 00000000..e01e8d2e Binary files /dev/null and b/web/public/images/collection-templates/mdblist_seasonal_halloween.jpg differ diff --git a/web/public/images/collection-templates/mdblist_seasonal_latinx_heritage_month.jpg b/web/public/images/collection-templates/mdblist_seasonal_latinx_heritage_month.jpg new file mode 100644 index 00000000..288fee3b Binary files /dev/null and b/web/public/images/collection-templates/mdblist_seasonal_latinx_heritage_month.jpg differ diff --git a/web/public/images/collection-templates/mdblist_seasonal_new_year.jpg b/web/public/images/collection-templates/mdblist_seasonal_new_year.jpg new file mode 100644 index 00000000..bd4467e6 Binary files /dev/null and b/web/public/images/collection-templates/mdblist_seasonal_new_year.jpg differ diff --git a/web/public/images/collection-templates/mdblist_seasonal_pride_month.jpg b/web/public/images/collection-templates/mdblist_seasonal_pride_month.jpg new file mode 100644 index 00000000..59edd682 Binary files /dev/null and b/web/public/images/collection-templates/mdblist_seasonal_pride_month.jpg differ diff --git a/web/public/images/collection-templates/mdblist_seasonal_thanksgiving.jpg b/web/public/images/collection-templates/mdblist_seasonal_thanksgiving.jpg new file mode 100644 index 00000000..5b97023e Binary files /dev/null and b/web/public/images/collection-templates/mdblist_seasonal_thanksgiving.jpg differ diff --git a/web/public/images/collection-templates/mdblist_seasonal_valentines_day.jpg b/web/public/images/collection-templates/mdblist_seasonal_valentines_day.jpg new file mode 100644 index 00000000..c45a801c Binary files /dev/null and b/web/public/images/collection-templates/mdblist_seasonal_valentines_day.jpg differ diff --git a/web/public/images/collection-templates/mdblist_streaming_apple_tv_plus.jpg b/web/public/images/collection-templates/mdblist_streaming_apple_tv_plus.jpg new file mode 100644 index 00000000..ea6604e1 Binary files /dev/null and b/web/public/images/collection-templates/mdblist_streaming_apple_tv_plus.jpg differ diff --git a/web/public/images/collection-templates/mdblist_streaming_disney_plus_originals.jpg b/web/public/images/collection-templates/mdblist_streaming_disney_plus_originals.jpg new file mode 100644 index 00000000..dd306b18 Binary files /dev/null and b/web/public/images/collection-templates/mdblist_streaming_disney_plus_originals.jpg differ diff --git a/web/public/images/collection-templates/mdblist_streaming_hbo_max_originals.jpg b/web/public/images/collection-templates/mdblist_streaming_hbo_max_originals.jpg new file mode 100644 index 00000000..b8953828 Binary files /dev/null and b/web/public/images/collection-templates/mdblist_streaming_hbo_max_originals.jpg differ diff --git a/web/public/images/collection-templates/mdblist_streaming_hulu_originals.jpg b/web/public/images/collection-templates/mdblist_streaming_hulu_originals.jpg new file mode 100644 index 00000000..09b23c39 Binary files /dev/null and b/web/public/images/collection-templates/mdblist_streaming_hulu_originals.jpg differ diff --git a/web/public/images/collection-templates/mdblist_streaming_netflix_originals.jpg b/web/public/images/collection-templates/mdblist_streaming_netflix_originals.jpg new file mode 100644 index 00000000..a3bb7f82 Binary files /dev/null and b/web/public/images/collection-templates/mdblist_streaming_netflix_originals.jpg differ diff --git a/web/public/images/collection-templates/mdblist_streaming_peacock_originals.jpg b/web/public/images/collection-templates/mdblist_streaming_peacock_originals.jpg new file mode 100644 index 00000000..696671bd Binary files /dev/null and b/web/public/images/collection-templates/mdblist_streaming_peacock_originals.jpg differ diff --git a/web/public/images/collection-templates/mdblist_streaming_prime_video_originals.jpg b/web/public/images/collection-templates/mdblist_streaming_prime_video_originals.jpg new file mode 100644 index 00000000..1968050f Binary files /dev/null and b/web/public/images/collection-templates/mdblist_streaming_prime_video_originals.jpg differ diff --git a/web/public/images/collection-templates/mdblist_streaming_shudder.jpg b/web/public/images/collection-templates/mdblist_streaming_shudder.jpg new file mode 100644 index 00000000..14b18a8e Binary files /dev/null and b/web/public/images/collection-templates/mdblist_streaming_shudder.jpg differ diff --git a/web/public/images/collection-templates/raw/mdblist_awards_golden_globes_winners.png b/web/public/images/collection-templates/raw/mdblist_awards_golden_globes_winners.png new file mode 100644 index 00000000..a1f5fac0 Binary files /dev/null and b/web/public/images/collection-templates/raw/mdblist_awards_golden_globes_winners.png differ diff --git a/web/public/images/collection-templates/raw/mdblist_awards_oscar_winners.png b/web/public/images/collection-templates/raw/mdblist_awards_oscar_winners.png new file mode 100644 index 00000000..3520b7aa Binary files /dev/null and b/web/public/images/collection-templates/raw/mdblist_awards_oscar_winners.png differ diff --git a/web/public/images/collection-templates/raw/mdblist_best_of_2023.png b/web/public/images/collection-templates/raw/mdblist_best_of_2023.png new file mode 100644 index 00000000..b45bb3d6 Binary files /dev/null and b/web/public/images/collection-templates/raw/mdblist_best_of_2023.png differ diff --git a/web/public/images/collection-templates/raw/mdblist_best_of_2024.png b/web/public/images/collection-templates/raw/mdblist_best_of_2024.png new file mode 100644 index 00000000..4c42221d Binary files /dev/null and b/web/public/images/collection-templates/raw/mdblist_best_of_2024.png differ diff --git a/web/public/images/collection-templates/raw/mdblist_best_of_2025.png b/web/public/images/collection-templates/raw/mdblist_best_of_2025.png new file mode 100644 index 00000000..27676096 Binary files /dev/null and b/web/public/images/collection-templates/raw/mdblist_best_of_2025.png differ diff --git a/web/public/images/collection-templates/raw/mdblist_charts_popular_movies.png b/web/public/images/collection-templates/raw/mdblist_charts_popular_movies.png new file mode 100644 index 00000000..4a1ef4f2 Binary files /dev/null and b/web/public/images/collection-templates/raw/mdblist_charts_popular_movies.png differ diff --git a/web/public/images/collection-templates/raw/mdblist_misc_a24.png b/web/public/images/collection-templates/raw/mdblist_misc_a24.png new file mode 100644 index 00000000..03970c5b Binary files /dev/null and b/web/public/images/collection-templates/raw/mdblist_misc_a24.png differ diff --git a/web/public/images/collection-templates/raw/mdblist_misc_criterion_collection.png b/web/public/images/collection-templates/raw/mdblist_misc_criterion_collection.png new file mode 100644 index 00000000..8f27b51c Binary files /dev/null and b/web/public/images/collection-templates/raw/mdblist_misc_criterion_collection.png differ diff --git a/web/public/images/collection-templates/raw/mdblist_misc_ifc_films.png b/web/public/images/collection-templates/raw/mdblist_misc_ifc_films.png new file mode 100644 index 00000000..a6c9621e Binary files /dev/null and b/web/public/images/collection-templates/raw/mdblist_misc_ifc_films.png differ diff --git a/web/public/images/collection-templates/raw/mdblist_misc_studio_ghibli.png b/web/public/images/collection-templates/raw/mdblist_misc_studio_ghibli.png new file mode 100644 index 00000000..8f50f56d Binary files /dev/null and b/web/public/images/collection-templates/raw/mdblist_misc_studio_ghibli.png differ diff --git a/web/public/images/collection-templates/raw/mdblist_seasonal_aapi_heritage_month.png b/web/public/images/collection-templates/raw/mdblist_seasonal_aapi_heritage_month.png new file mode 100644 index 00000000..ae9a0072 Binary files /dev/null and b/web/public/images/collection-templates/raw/mdblist_seasonal_aapi_heritage_month.png differ diff --git a/web/public/images/collection-templates/raw/mdblist_seasonal_christmas.png b/web/public/images/collection-templates/raw/mdblist_seasonal_christmas.png new file mode 100644 index 00000000..60a2f1e2 Binary files /dev/null and b/web/public/images/collection-templates/raw/mdblist_seasonal_christmas.png differ diff --git a/web/public/images/collection-templates/raw/mdblist_seasonal_easter.png b/web/public/images/collection-templates/raw/mdblist_seasonal_easter.png new file mode 100644 index 00000000..506b94d6 Binary files /dev/null and b/web/public/images/collection-templates/raw/mdblist_seasonal_easter.png differ diff --git a/web/public/images/collection-templates/raw/mdblist_seasonal_halloween.png b/web/public/images/collection-templates/raw/mdblist_seasonal_halloween.png new file mode 100644 index 00000000..4657d928 Binary files /dev/null and b/web/public/images/collection-templates/raw/mdblist_seasonal_halloween.png differ diff --git a/web/public/images/collection-templates/raw/mdblist_seasonal_latinx_heritage_month.png b/web/public/images/collection-templates/raw/mdblist_seasonal_latinx_heritage_month.png new file mode 100644 index 00000000..f882d6bf Binary files /dev/null and b/web/public/images/collection-templates/raw/mdblist_seasonal_latinx_heritage_month.png differ diff --git a/web/public/images/collection-templates/raw/mdblist_seasonal_new_year.png b/web/public/images/collection-templates/raw/mdblist_seasonal_new_year.png new file mode 100644 index 00000000..aedc3a35 Binary files /dev/null and b/web/public/images/collection-templates/raw/mdblist_seasonal_new_year.png differ diff --git a/web/public/images/collection-templates/raw/mdblist_seasonal_pride_month.png b/web/public/images/collection-templates/raw/mdblist_seasonal_pride_month.png new file mode 100644 index 00000000..166d5e7f Binary files /dev/null and b/web/public/images/collection-templates/raw/mdblist_seasonal_pride_month.png differ diff --git a/web/public/images/collection-templates/raw/mdblist_seasonal_thanksgiving.png b/web/public/images/collection-templates/raw/mdblist_seasonal_thanksgiving.png new file mode 100644 index 00000000..96d76796 Binary files /dev/null and b/web/public/images/collection-templates/raw/mdblist_seasonal_thanksgiving.png differ diff --git a/web/public/images/collection-templates/raw/mdblist_seasonal_valentines_day.png b/web/public/images/collection-templates/raw/mdblist_seasonal_valentines_day.png new file mode 100644 index 00000000..79a5d526 Binary files /dev/null and b/web/public/images/collection-templates/raw/mdblist_seasonal_valentines_day.png differ diff --git a/web/public/images/collection-templates/raw/mdblist_streaming_apple_tv_plus.png b/web/public/images/collection-templates/raw/mdblist_streaming_apple_tv_plus.png new file mode 100644 index 00000000..983d3d79 Binary files /dev/null and b/web/public/images/collection-templates/raw/mdblist_streaming_apple_tv_plus.png differ diff --git a/web/public/images/collection-templates/raw/mdblist_streaming_disney_plus_originals.png b/web/public/images/collection-templates/raw/mdblist_streaming_disney_plus_originals.png new file mode 100644 index 00000000..77567e85 Binary files /dev/null and b/web/public/images/collection-templates/raw/mdblist_streaming_disney_plus_originals.png differ diff --git a/web/public/images/collection-templates/raw/mdblist_streaming_hbo_max_originals.png b/web/public/images/collection-templates/raw/mdblist_streaming_hbo_max_originals.png new file mode 100644 index 00000000..01080351 Binary files /dev/null and b/web/public/images/collection-templates/raw/mdblist_streaming_hbo_max_originals.png differ diff --git a/web/public/images/collection-templates/raw/mdblist_streaming_hulu_originals.png b/web/public/images/collection-templates/raw/mdblist_streaming_hulu_originals.png new file mode 100644 index 00000000..7e645620 Binary files /dev/null and b/web/public/images/collection-templates/raw/mdblist_streaming_hulu_originals.png differ diff --git a/web/public/images/collection-templates/raw/mdblist_streaming_netflix_originals.png b/web/public/images/collection-templates/raw/mdblist_streaming_netflix_originals.png new file mode 100644 index 00000000..ff86c3d8 Binary files /dev/null and b/web/public/images/collection-templates/raw/mdblist_streaming_netflix_originals.png differ diff --git a/web/public/images/collection-templates/raw/mdblist_streaming_peacock_originals.png b/web/public/images/collection-templates/raw/mdblist_streaming_peacock_originals.png new file mode 100644 index 00000000..bca54099 Binary files /dev/null and b/web/public/images/collection-templates/raw/mdblist_streaming_peacock_originals.png differ diff --git a/web/public/images/collection-templates/raw/mdblist_streaming_prime_video_originals.png b/web/public/images/collection-templates/raw/mdblist_streaming_prime_video_originals.png new file mode 100644 index 00000000..7d1ae7d7 Binary files /dev/null and b/web/public/images/collection-templates/raw/mdblist_streaming_prime_video_originals.png differ diff --git a/web/public/images/collection-templates/raw/mdblist_streaming_shudder.png b/web/public/images/collection-templates/raw/mdblist_streaming_shudder.png new file mode 100644 index 00000000..fbb5f029 Binary files /dev/null and b/web/public/images/collection-templates/raw/mdblist_streaming_shudder.png differ diff --git a/web/public/images/collection-templates/raw/tmdb_discover_kids_movies.png b/web/public/images/collection-templates/raw/tmdb_discover_kids_movies.png new file mode 100644 index 00000000..dc7e523d Binary files /dev/null and b/web/public/images/collection-templates/raw/tmdb_discover_kids_movies.png differ diff --git a/web/public/images/collection-templates/raw/tmdb_discover_popular_action.png b/web/public/images/collection-templates/raw/tmdb_discover_popular_action.png new file mode 100644 index 00000000..309f04d5 Binary files /dev/null and b/web/public/images/collection-templates/raw/tmdb_discover_popular_action.png differ diff --git a/web/public/images/collection-templates/raw/tmdb_discover_popular_adventure.png b/web/public/images/collection-templates/raw/tmdb_discover_popular_adventure.png new file mode 100644 index 00000000..a1516692 Binary files /dev/null and b/web/public/images/collection-templates/raw/tmdb_discover_popular_adventure.png differ diff --git a/web/public/images/collection-templates/raw/tmdb_discover_popular_animation.png b/web/public/images/collection-templates/raw/tmdb_discover_popular_animation.png new file mode 100644 index 00000000..58f17d87 Binary files /dev/null and b/web/public/images/collection-templates/raw/tmdb_discover_popular_animation.png differ diff --git a/web/public/images/collection-templates/raw/tmdb_discover_popular_comedy.png b/web/public/images/collection-templates/raw/tmdb_discover_popular_comedy.png new file mode 100644 index 00000000..26affc7e Binary files /dev/null and b/web/public/images/collection-templates/raw/tmdb_discover_popular_comedy.png differ diff --git a/web/public/images/collection-templates/raw/tmdb_discover_popular_crime.png b/web/public/images/collection-templates/raw/tmdb_discover_popular_crime.png new file mode 100644 index 00000000..38e035c1 Binary files /dev/null and b/web/public/images/collection-templates/raw/tmdb_discover_popular_crime.png differ diff --git a/web/public/images/collection-templates/raw/tmdb_discover_popular_documentary.png b/web/public/images/collection-templates/raw/tmdb_discover_popular_documentary.png new file mode 100644 index 00000000..c698a47f Binary files /dev/null and b/web/public/images/collection-templates/raw/tmdb_discover_popular_documentary.png differ diff --git a/web/public/images/collection-templates/raw/tmdb_discover_popular_drama.png b/web/public/images/collection-templates/raw/tmdb_discover_popular_drama.png new file mode 100644 index 00000000..aa6988d0 Binary files /dev/null and b/web/public/images/collection-templates/raw/tmdb_discover_popular_drama.png differ diff --git a/web/public/images/collection-templates/raw/tmdb_discover_popular_family.png b/web/public/images/collection-templates/raw/tmdb_discover_popular_family.png new file mode 100644 index 00000000..f4c94537 Binary files /dev/null and b/web/public/images/collection-templates/raw/tmdb_discover_popular_family.png differ diff --git a/web/public/images/collection-templates/raw/tmdb_discover_popular_fantasy.png b/web/public/images/collection-templates/raw/tmdb_discover_popular_fantasy.png new file mode 100644 index 00000000..067bdfe2 Binary files /dev/null and b/web/public/images/collection-templates/raw/tmdb_discover_popular_fantasy.png differ diff --git a/web/public/images/collection-templates/raw/tmdb_discover_popular_history.png b/web/public/images/collection-templates/raw/tmdb_discover_popular_history.png new file mode 100644 index 00000000..eafe82fe Binary files /dev/null and b/web/public/images/collection-templates/raw/tmdb_discover_popular_history.png differ diff --git a/web/public/images/collection-templates/raw/tmdb_discover_popular_horror.png b/web/public/images/collection-templates/raw/tmdb_discover_popular_horror.png new file mode 100644 index 00000000..51892162 Binary files /dev/null and b/web/public/images/collection-templates/raw/tmdb_discover_popular_horror.png differ diff --git a/web/public/images/collection-templates/raw/tmdb_discover_popular_music.png b/web/public/images/collection-templates/raw/tmdb_discover_popular_music.png new file mode 100644 index 00000000..daaef19b Binary files /dev/null and b/web/public/images/collection-templates/raw/tmdb_discover_popular_music.png differ diff --git a/web/public/images/collection-templates/raw/tmdb_discover_popular_mystery.png b/web/public/images/collection-templates/raw/tmdb_discover_popular_mystery.png new file mode 100644 index 00000000..7ae2bf0d Binary files /dev/null and b/web/public/images/collection-templates/raw/tmdb_discover_popular_mystery.png differ diff --git a/web/public/images/collection-templates/raw/tmdb_discover_popular_romance.png b/web/public/images/collection-templates/raw/tmdb_discover_popular_romance.png new file mode 100644 index 00000000..9794ae50 Binary files /dev/null and b/web/public/images/collection-templates/raw/tmdb_discover_popular_romance.png differ diff --git a/web/public/images/collection-templates/raw/tmdb_discover_popular_science_fiction.png b/web/public/images/collection-templates/raw/tmdb_discover_popular_science_fiction.png new file mode 100644 index 00000000..7e7165e7 Binary files /dev/null and b/web/public/images/collection-templates/raw/tmdb_discover_popular_science_fiction.png differ diff --git a/web/public/images/collection-templates/raw/tmdb_discover_popular_thriller.png b/web/public/images/collection-templates/raw/tmdb_discover_popular_thriller.png new file mode 100644 index 00000000..9308919f Binary files /dev/null and b/web/public/images/collection-templates/raw/tmdb_discover_popular_thriller.png differ diff --git a/web/public/images/collection-templates/raw/tmdb_discover_popular_war.png b/web/public/images/collection-templates/raw/tmdb_discover_popular_war.png new file mode 100644 index 00000000..870b554f Binary files /dev/null and b/web/public/images/collection-templates/raw/tmdb_discover_popular_war.png differ diff --git a/web/public/images/collection-templates/raw/tmdb_discover_popular_western.png b/web/public/images/collection-templates/raw/tmdb_discover_popular_western.png new file mode 100644 index 00000000..eb098225 Binary files /dev/null and b/web/public/images/collection-templates/raw/tmdb_discover_popular_western.png differ diff --git a/web/public/images/collection-templates/raw/tmdb_discover_top_rated_action.png b/web/public/images/collection-templates/raw/tmdb_discover_top_rated_action.png new file mode 100644 index 00000000..d0f53aea Binary files /dev/null and b/web/public/images/collection-templates/raw/tmdb_discover_top_rated_action.png differ diff --git a/web/public/images/collection-templates/raw/tmdb_discover_top_rated_adventure.png b/web/public/images/collection-templates/raw/tmdb_discover_top_rated_adventure.png new file mode 100644 index 00000000..8410f792 Binary files /dev/null and b/web/public/images/collection-templates/raw/tmdb_discover_top_rated_adventure.png differ diff --git a/web/public/images/collection-templates/raw/tmdb_discover_top_rated_animation.png b/web/public/images/collection-templates/raw/tmdb_discover_top_rated_animation.png new file mode 100644 index 00000000..7dc57bad Binary files /dev/null and b/web/public/images/collection-templates/raw/tmdb_discover_top_rated_animation.png differ diff --git a/web/public/images/collection-templates/raw/tmdb_discover_top_rated_comedy.png b/web/public/images/collection-templates/raw/tmdb_discover_top_rated_comedy.png new file mode 100644 index 00000000..2a330ed1 Binary files /dev/null and b/web/public/images/collection-templates/raw/tmdb_discover_top_rated_comedy.png differ diff --git a/web/public/images/collection-templates/raw/tmdb_discover_top_rated_crime.png b/web/public/images/collection-templates/raw/tmdb_discover_top_rated_crime.png new file mode 100644 index 00000000..68da66c0 Binary files /dev/null and b/web/public/images/collection-templates/raw/tmdb_discover_top_rated_crime.png differ diff --git a/web/public/images/collection-templates/raw/tmdb_discover_top_rated_documentary.png b/web/public/images/collection-templates/raw/tmdb_discover_top_rated_documentary.png new file mode 100644 index 00000000..76f9d777 Binary files /dev/null and b/web/public/images/collection-templates/raw/tmdb_discover_top_rated_documentary.png differ diff --git a/web/public/images/collection-templates/raw/tmdb_discover_top_rated_drama.png b/web/public/images/collection-templates/raw/tmdb_discover_top_rated_drama.png new file mode 100644 index 00000000..b058b273 Binary files /dev/null and b/web/public/images/collection-templates/raw/tmdb_discover_top_rated_drama.png differ diff --git a/web/public/images/collection-templates/raw/tmdb_discover_top_rated_family.png b/web/public/images/collection-templates/raw/tmdb_discover_top_rated_family.png new file mode 100644 index 00000000..80c26774 Binary files /dev/null and b/web/public/images/collection-templates/raw/tmdb_discover_top_rated_family.png differ diff --git a/web/public/images/collection-templates/raw/tmdb_discover_top_rated_fantasy.png b/web/public/images/collection-templates/raw/tmdb_discover_top_rated_fantasy.png new file mode 100644 index 00000000..bcdc4a0b Binary files /dev/null and b/web/public/images/collection-templates/raw/tmdb_discover_top_rated_fantasy.png differ diff --git a/web/public/images/collection-templates/raw/tmdb_discover_top_rated_history.png b/web/public/images/collection-templates/raw/tmdb_discover_top_rated_history.png new file mode 100644 index 00000000..4aa5b3b7 Binary files /dev/null and b/web/public/images/collection-templates/raw/tmdb_discover_top_rated_history.png differ diff --git a/web/public/images/collection-templates/raw/tmdb_discover_top_rated_horror.png b/web/public/images/collection-templates/raw/tmdb_discover_top_rated_horror.png new file mode 100644 index 00000000..4406b63a Binary files /dev/null and b/web/public/images/collection-templates/raw/tmdb_discover_top_rated_horror.png differ diff --git a/web/public/images/collection-templates/raw/tmdb_discover_top_rated_music.png b/web/public/images/collection-templates/raw/tmdb_discover_top_rated_music.png new file mode 100644 index 00000000..6c762051 Binary files /dev/null and b/web/public/images/collection-templates/raw/tmdb_discover_top_rated_music.png differ diff --git a/web/public/images/collection-templates/raw/tmdb_discover_top_rated_mystery.png b/web/public/images/collection-templates/raw/tmdb_discover_top_rated_mystery.png new file mode 100644 index 00000000..7ef0fe4d Binary files /dev/null and b/web/public/images/collection-templates/raw/tmdb_discover_top_rated_mystery.png differ diff --git a/web/public/images/collection-templates/raw/tmdb_discover_top_rated_romance.png b/web/public/images/collection-templates/raw/tmdb_discover_top_rated_romance.png new file mode 100644 index 00000000..cf333d18 Binary files /dev/null and b/web/public/images/collection-templates/raw/tmdb_discover_top_rated_romance.png differ diff --git a/web/public/images/collection-templates/raw/tmdb_discover_top_rated_science_fiction.png b/web/public/images/collection-templates/raw/tmdb_discover_top_rated_science_fiction.png new file mode 100644 index 00000000..0d377be5 Binary files /dev/null and b/web/public/images/collection-templates/raw/tmdb_discover_top_rated_science_fiction.png differ diff --git a/web/public/images/collection-templates/raw/tmdb_discover_top_rated_thriller.png b/web/public/images/collection-templates/raw/tmdb_discover_top_rated_thriller.png new file mode 100644 index 00000000..089adbf2 Binary files /dev/null and b/web/public/images/collection-templates/raw/tmdb_discover_top_rated_thriller.png differ diff --git a/web/public/images/collection-templates/raw/tmdb_discover_top_rated_war.png b/web/public/images/collection-templates/raw/tmdb_discover_top_rated_war.png new file mode 100644 index 00000000..f49b66fb Binary files /dev/null and b/web/public/images/collection-templates/raw/tmdb_discover_top_rated_war.png differ diff --git a/web/public/images/collection-templates/raw/tmdb_discover_top_rated_western.png b/web/public/images/collection-templates/raw/tmdb_discover_top_rated_western.png new file mode 100644 index 00000000..fface6e1 Binary files /dev/null and b/web/public/images/collection-templates/raw/tmdb_discover_top_rated_western.png differ diff --git a/web/public/images/collection-templates/raw/tmdb_franchise_fast_furious.png b/web/public/images/collection-templates/raw/tmdb_franchise_fast_furious.png new file mode 100644 index 00000000..ccad3ab2 Binary files /dev/null and b/web/public/images/collection-templates/raw/tmdb_franchise_fast_furious.png differ diff --git a/web/public/images/collection-templates/raw/tmdb_franchise_hobbit.png b/web/public/images/collection-templates/raw/tmdb_franchise_hobbit.png new file mode 100644 index 00000000..356faf0a Binary files /dev/null and b/web/public/images/collection-templates/raw/tmdb_franchise_hobbit.png differ diff --git a/web/public/images/collection-templates/raw/tmdb_franchise_james_bond.png b/web/public/images/collection-templates/raw/tmdb_franchise_james_bond.png new file mode 100644 index 00000000..50fdad5e Binary files /dev/null and b/web/public/images/collection-templates/raw/tmdb_franchise_james_bond.png differ diff --git a/web/public/images/collection-templates/raw/tmdb_franchise_jurassic_park.png b/web/public/images/collection-templates/raw/tmdb_franchise_jurassic_park.png new file mode 100644 index 00000000..03ddd5a8 Binary files /dev/null and b/web/public/images/collection-templates/raw/tmdb_franchise_jurassic_park.png differ diff --git a/web/public/images/collection-templates/raw/tmdb_franchise_lord_of_the_rings.png b/web/public/images/collection-templates/raw/tmdb_franchise_lord_of_the_rings.png new file mode 100644 index 00000000..dd1080f1 Binary files /dev/null and b/web/public/images/collection-templates/raw/tmdb_franchise_lord_of_the_rings.png differ diff --git a/web/public/images/collection-templates/raw/tmdb_franchise_mission_impossible.png b/web/public/images/collection-templates/raw/tmdb_franchise_mission_impossible.png new file mode 100644 index 00000000..26d1fff1 Binary files /dev/null and b/web/public/images/collection-templates/raw/tmdb_franchise_mission_impossible.png differ diff --git a/web/public/images/collection-templates/raw/tmdb_franchise_monsterverse.png b/web/public/images/collection-templates/raw/tmdb_franchise_monsterverse.png new file mode 100644 index 00000000..979a6027 Binary files /dev/null and b/web/public/images/collection-templates/raw/tmdb_franchise_monsterverse.png differ diff --git a/web/public/images/collection-templates/raw/tmdb_franchise_pirates_of_the_caribbean.png b/web/public/images/collection-templates/raw/tmdb_franchise_pirates_of_the_caribbean.png new file mode 100644 index 00000000..04909fb2 Binary files /dev/null and b/web/public/images/collection-templates/raw/tmdb_franchise_pirates_of_the_caribbean.png differ diff --git a/web/public/images/collection-templates/raw/tmdb_franchise_placeholder.png b/web/public/images/collection-templates/raw/tmdb_franchise_placeholder.png new file mode 100644 index 00000000..37814153 Binary files /dev/null and b/web/public/images/collection-templates/raw/tmdb_franchise_placeholder.png differ diff --git a/web/public/images/collection-templates/raw/tmdb_franchise_star_wars.png b/web/public/images/collection-templates/raw/tmdb_franchise_star_wars.png new file mode 100644 index 00000000..18a50ae6 Binary files /dev/null and b/web/public/images/collection-templates/raw/tmdb_franchise_star_wars.png differ diff --git a/web/public/images/collection-templates/raw/tmdb_franchise_wizarding_world.png b/web/public/images/collection-templates/raw/tmdb_franchise_wizarding_world.png new file mode 100644 index 00000000..1fd9ed0b Binary files /dev/null and b/web/public/images/collection-templates/raw/tmdb_franchise_wizarding_world.png differ diff --git a/web/public/images/collection-templates/tmdb_discover_kids_movies.jpg b/web/public/images/collection-templates/tmdb_discover_kids_movies.jpg new file mode 100644 index 00000000..9ce426d6 Binary files /dev/null and b/web/public/images/collection-templates/tmdb_discover_kids_movies.jpg differ diff --git a/web/public/images/collection-templates/tmdb_discover_popular_action.jpg b/web/public/images/collection-templates/tmdb_discover_popular_action.jpg new file mode 100644 index 00000000..ea4913d0 Binary files /dev/null and b/web/public/images/collection-templates/tmdb_discover_popular_action.jpg differ diff --git a/web/public/images/collection-templates/tmdb_discover_popular_adventure.jpg b/web/public/images/collection-templates/tmdb_discover_popular_adventure.jpg new file mode 100644 index 00000000..ea0bce9c Binary files /dev/null and b/web/public/images/collection-templates/tmdb_discover_popular_adventure.jpg differ diff --git a/web/public/images/collection-templates/tmdb_discover_popular_animation.jpg b/web/public/images/collection-templates/tmdb_discover_popular_animation.jpg new file mode 100644 index 00000000..c374340a Binary files /dev/null and b/web/public/images/collection-templates/tmdb_discover_popular_animation.jpg differ diff --git a/web/public/images/collection-templates/tmdb_discover_popular_comedy.jpg b/web/public/images/collection-templates/tmdb_discover_popular_comedy.jpg new file mode 100644 index 00000000..3ec577b8 Binary files /dev/null and b/web/public/images/collection-templates/tmdb_discover_popular_comedy.jpg differ diff --git a/web/public/images/collection-templates/tmdb_discover_popular_crime.jpg b/web/public/images/collection-templates/tmdb_discover_popular_crime.jpg new file mode 100644 index 00000000..e8e9b3c0 Binary files /dev/null and b/web/public/images/collection-templates/tmdb_discover_popular_crime.jpg differ diff --git a/web/public/images/collection-templates/tmdb_discover_popular_documentary.jpg b/web/public/images/collection-templates/tmdb_discover_popular_documentary.jpg new file mode 100644 index 00000000..b6ef21bd Binary files /dev/null and b/web/public/images/collection-templates/tmdb_discover_popular_documentary.jpg differ diff --git a/web/public/images/collection-templates/tmdb_discover_popular_drama.jpg b/web/public/images/collection-templates/tmdb_discover_popular_drama.jpg new file mode 100644 index 00000000..0935f7db Binary files /dev/null and b/web/public/images/collection-templates/tmdb_discover_popular_drama.jpg differ diff --git a/web/public/images/collection-templates/tmdb_discover_popular_family.jpg b/web/public/images/collection-templates/tmdb_discover_popular_family.jpg new file mode 100644 index 00000000..5b750a13 Binary files /dev/null and b/web/public/images/collection-templates/tmdb_discover_popular_family.jpg differ diff --git a/web/public/images/collection-templates/tmdb_discover_popular_fantasy.jpg b/web/public/images/collection-templates/tmdb_discover_popular_fantasy.jpg new file mode 100644 index 00000000..36765ccd Binary files /dev/null and b/web/public/images/collection-templates/tmdb_discover_popular_fantasy.jpg differ diff --git a/web/public/images/collection-templates/tmdb_discover_popular_history.jpg b/web/public/images/collection-templates/tmdb_discover_popular_history.jpg new file mode 100644 index 00000000..80410eb8 Binary files /dev/null and b/web/public/images/collection-templates/tmdb_discover_popular_history.jpg differ diff --git a/web/public/images/collection-templates/tmdb_discover_popular_horror.jpg b/web/public/images/collection-templates/tmdb_discover_popular_horror.jpg new file mode 100644 index 00000000..a0e92729 Binary files /dev/null and b/web/public/images/collection-templates/tmdb_discover_popular_horror.jpg differ diff --git a/web/public/images/collection-templates/tmdb_discover_popular_music.jpg b/web/public/images/collection-templates/tmdb_discover_popular_music.jpg new file mode 100644 index 00000000..9915ae4f Binary files /dev/null and b/web/public/images/collection-templates/tmdb_discover_popular_music.jpg differ diff --git a/web/public/images/collection-templates/tmdb_discover_popular_mystery.jpg b/web/public/images/collection-templates/tmdb_discover_popular_mystery.jpg new file mode 100644 index 00000000..282526ea Binary files /dev/null and b/web/public/images/collection-templates/tmdb_discover_popular_mystery.jpg differ diff --git a/web/public/images/collection-templates/tmdb_discover_popular_romance.jpg b/web/public/images/collection-templates/tmdb_discover_popular_romance.jpg new file mode 100644 index 00000000..56ae3507 Binary files /dev/null and b/web/public/images/collection-templates/tmdb_discover_popular_romance.jpg differ diff --git a/web/public/images/collection-templates/tmdb_discover_popular_science_fiction.jpg b/web/public/images/collection-templates/tmdb_discover_popular_science_fiction.jpg new file mode 100644 index 00000000..0ae19873 Binary files /dev/null and b/web/public/images/collection-templates/tmdb_discover_popular_science_fiction.jpg differ diff --git a/web/public/images/collection-templates/tmdb_discover_popular_thriller.jpg b/web/public/images/collection-templates/tmdb_discover_popular_thriller.jpg new file mode 100644 index 00000000..3c644562 Binary files /dev/null and b/web/public/images/collection-templates/tmdb_discover_popular_thriller.jpg differ diff --git a/web/public/images/collection-templates/tmdb_discover_popular_war.jpg b/web/public/images/collection-templates/tmdb_discover_popular_war.jpg new file mode 100644 index 00000000..14676889 Binary files /dev/null and b/web/public/images/collection-templates/tmdb_discover_popular_war.jpg differ diff --git a/web/public/images/collection-templates/tmdb_discover_popular_western.jpg b/web/public/images/collection-templates/tmdb_discover_popular_western.jpg new file mode 100644 index 00000000..583134e0 Binary files /dev/null and b/web/public/images/collection-templates/tmdb_discover_popular_western.jpg differ diff --git a/web/public/images/collection-templates/tmdb_discover_top_rated_action.jpg b/web/public/images/collection-templates/tmdb_discover_top_rated_action.jpg new file mode 100644 index 00000000..87b23432 Binary files /dev/null and b/web/public/images/collection-templates/tmdb_discover_top_rated_action.jpg differ diff --git a/web/public/images/collection-templates/tmdb_discover_top_rated_adventure.jpg b/web/public/images/collection-templates/tmdb_discover_top_rated_adventure.jpg new file mode 100644 index 00000000..3f1e9c72 Binary files /dev/null and b/web/public/images/collection-templates/tmdb_discover_top_rated_adventure.jpg differ diff --git a/web/public/images/collection-templates/tmdb_discover_top_rated_animation.jpg b/web/public/images/collection-templates/tmdb_discover_top_rated_animation.jpg new file mode 100644 index 00000000..18e44392 Binary files /dev/null and b/web/public/images/collection-templates/tmdb_discover_top_rated_animation.jpg differ diff --git a/web/public/images/collection-templates/tmdb_discover_top_rated_comedy.jpg b/web/public/images/collection-templates/tmdb_discover_top_rated_comedy.jpg new file mode 100644 index 00000000..f9e0fc33 Binary files /dev/null and b/web/public/images/collection-templates/tmdb_discover_top_rated_comedy.jpg differ diff --git a/web/public/images/collection-templates/tmdb_discover_top_rated_crime.jpg b/web/public/images/collection-templates/tmdb_discover_top_rated_crime.jpg new file mode 100644 index 00000000..914208a1 Binary files /dev/null and b/web/public/images/collection-templates/tmdb_discover_top_rated_crime.jpg differ diff --git a/web/public/images/collection-templates/tmdb_discover_top_rated_documentary.jpg b/web/public/images/collection-templates/tmdb_discover_top_rated_documentary.jpg new file mode 100644 index 00000000..a58a657f Binary files /dev/null and b/web/public/images/collection-templates/tmdb_discover_top_rated_documentary.jpg differ diff --git a/web/public/images/collection-templates/tmdb_discover_top_rated_drama.jpg b/web/public/images/collection-templates/tmdb_discover_top_rated_drama.jpg new file mode 100644 index 00000000..2a2075e8 Binary files /dev/null and b/web/public/images/collection-templates/tmdb_discover_top_rated_drama.jpg differ diff --git a/web/public/images/collection-templates/tmdb_discover_top_rated_family.jpg b/web/public/images/collection-templates/tmdb_discover_top_rated_family.jpg new file mode 100644 index 00000000..6d2fe852 Binary files /dev/null and b/web/public/images/collection-templates/tmdb_discover_top_rated_family.jpg differ diff --git a/web/public/images/collection-templates/tmdb_discover_top_rated_fantasy.jpg b/web/public/images/collection-templates/tmdb_discover_top_rated_fantasy.jpg new file mode 100644 index 00000000..8ff92a82 Binary files /dev/null and b/web/public/images/collection-templates/tmdb_discover_top_rated_fantasy.jpg differ diff --git a/web/public/images/collection-templates/tmdb_discover_top_rated_history.jpg b/web/public/images/collection-templates/tmdb_discover_top_rated_history.jpg new file mode 100644 index 00000000..2ae7f9b6 Binary files /dev/null and b/web/public/images/collection-templates/tmdb_discover_top_rated_history.jpg differ diff --git a/web/public/images/collection-templates/tmdb_discover_top_rated_horror.jpg b/web/public/images/collection-templates/tmdb_discover_top_rated_horror.jpg new file mode 100644 index 00000000..a45a4c8f Binary files /dev/null and b/web/public/images/collection-templates/tmdb_discover_top_rated_horror.jpg differ diff --git a/web/public/images/collection-templates/tmdb_discover_top_rated_music.jpg b/web/public/images/collection-templates/tmdb_discover_top_rated_music.jpg new file mode 100644 index 00000000..e6b9847c Binary files /dev/null and b/web/public/images/collection-templates/tmdb_discover_top_rated_music.jpg differ diff --git a/web/public/images/collection-templates/tmdb_discover_top_rated_mystery.jpg b/web/public/images/collection-templates/tmdb_discover_top_rated_mystery.jpg new file mode 100644 index 00000000..fec96a90 Binary files /dev/null and b/web/public/images/collection-templates/tmdb_discover_top_rated_mystery.jpg differ diff --git a/web/public/images/collection-templates/tmdb_discover_top_rated_romance.jpg b/web/public/images/collection-templates/tmdb_discover_top_rated_romance.jpg new file mode 100644 index 00000000..46215973 Binary files /dev/null and b/web/public/images/collection-templates/tmdb_discover_top_rated_romance.jpg differ diff --git a/web/public/images/collection-templates/tmdb_discover_top_rated_science_fiction.jpg b/web/public/images/collection-templates/tmdb_discover_top_rated_science_fiction.jpg new file mode 100644 index 00000000..397490ab Binary files /dev/null and b/web/public/images/collection-templates/tmdb_discover_top_rated_science_fiction.jpg differ diff --git a/web/public/images/collection-templates/tmdb_discover_top_rated_thriller.jpg b/web/public/images/collection-templates/tmdb_discover_top_rated_thriller.jpg new file mode 100644 index 00000000..c2ef3f1d Binary files /dev/null and b/web/public/images/collection-templates/tmdb_discover_top_rated_thriller.jpg differ diff --git a/web/public/images/collection-templates/tmdb_discover_top_rated_war.jpg b/web/public/images/collection-templates/tmdb_discover_top_rated_war.jpg new file mode 100644 index 00000000..6af10988 Binary files /dev/null and b/web/public/images/collection-templates/tmdb_discover_top_rated_war.jpg differ diff --git a/web/public/images/collection-templates/tmdb_discover_top_rated_western.jpg b/web/public/images/collection-templates/tmdb_discover_top_rated_western.jpg new file mode 100644 index 00000000..2b110674 Binary files /dev/null and b/web/public/images/collection-templates/tmdb_discover_top_rated_western.jpg differ diff --git a/web/public/images/collection-templates/tmdb_franchise_fast_furious.jpg b/web/public/images/collection-templates/tmdb_franchise_fast_furious.jpg new file mode 100644 index 00000000..76b18ed2 Binary files /dev/null and b/web/public/images/collection-templates/tmdb_franchise_fast_furious.jpg differ diff --git a/web/public/images/collection-templates/tmdb_franchise_hobbit.jpg b/web/public/images/collection-templates/tmdb_franchise_hobbit.jpg new file mode 100644 index 00000000..e45e16c5 Binary files /dev/null and b/web/public/images/collection-templates/tmdb_franchise_hobbit.jpg differ diff --git a/web/public/images/collection-templates/tmdb_franchise_james_bond.jpg b/web/public/images/collection-templates/tmdb_franchise_james_bond.jpg new file mode 100644 index 00000000..9b0612da Binary files /dev/null and b/web/public/images/collection-templates/tmdb_franchise_james_bond.jpg differ diff --git a/web/public/images/collection-templates/tmdb_franchise_jurassic_park.jpg b/web/public/images/collection-templates/tmdb_franchise_jurassic_park.jpg new file mode 100644 index 00000000..eac85dd7 Binary files /dev/null and b/web/public/images/collection-templates/tmdb_franchise_jurassic_park.jpg differ diff --git a/web/public/images/collection-templates/tmdb_franchise_lord_of_the_rings.jpg b/web/public/images/collection-templates/tmdb_franchise_lord_of_the_rings.jpg new file mode 100644 index 00000000..28752e51 Binary files /dev/null and b/web/public/images/collection-templates/tmdb_franchise_lord_of_the_rings.jpg differ diff --git a/web/public/images/collection-templates/tmdb_franchise_mission_impossible.jpg b/web/public/images/collection-templates/tmdb_franchise_mission_impossible.jpg new file mode 100644 index 00000000..07c60a45 Binary files /dev/null and b/web/public/images/collection-templates/tmdb_franchise_mission_impossible.jpg differ diff --git a/web/public/images/collection-templates/tmdb_franchise_monsterverse.jpg b/web/public/images/collection-templates/tmdb_franchise_monsterverse.jpg new file mode 100644 index 00000000..9e039f52 Binary files /dev/null and b/web/public/images/collection-templates/tmdb_franchise_monsterverse.jpg differ diff --git a/web/public/images/collection-templates/tmdb_franchise_pirates_of_the_caribbean.jpg b/web/public/images/collection-templates/tmdb_franchise_pirates_of_the_caribbean.jpg new file mode 100644 index 00000000..53ae4451 Binary files /dev/null and b/web/public/images/collection-templates/tmdb_franchise_pirates_of_the_caribbean.jpg differ diff --git a/web/public/images/collection-templates/tmdb_franchise_placeholder.jpg b/web/public/images/collection-templates/tmdb_franchise_placeholder.jpg new file mode 100644 index 00000000..fa3ca0f1 Binary files /dev/null and b/web/public/images/collection-templates/tmdb_franchise_placeholder.jpg differ diff --git a/web/public/images/collection-templates/tmdb_franchise_star_wars.jpg b/web/public/images/collection-templates/tmdb_franchise_star_wars.jpg new file mode 100644 index 00000000..b3e0182d Binary files /dev/null and b/web/public/images/collection-templates/tmdb_franchise_star_wars.jpg differ diff --git a/web/public/images/collection-templates/tmdb_franchise_wizarding_world.jpg b/web/public/images/collection-templates/tmdb_franchise_wizarding_world.jpg new file mode 100644 index 00000000..49c54caf Binary files /dev/null and b/web/public/images/collection-templates/tmdb_franchise_wizarding_world.jpg differ diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 9af279b0..3c13f064 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -1234,6 +1234,7 @@ export interface ImportMDBListCollectionRequest { url: string; limit?: number; featured?: boolean; + poster_url?: string; poster_source_url?: string; backdrop_source_url?: string; sync_schedule?: string; @@ -1264,6 +1265,7 @@ export interface ImportTMDBCollectionRequest { media_type: "movie" | "tv" | "all"; limit?: number; featured?: boolean; + poster_url?: string; poster_source_url?: string; backdrop_source_url?: string; sync_schedule?: string; @@ -1287,6 +1289,7 @@ export interface ImportTraktCollectionRequest { profile_id?: string; limit?: number; featured?: boolean; + poster_url?: string; poster_source_url?: string; backdrop_source_url?: string; sync_schedule?: string; @@ -1311,6 +1314,7 @@ export interface UserImportSharedFields { limit?: number; sync_schedule?: UserCollectionSyncSchedule; is_shared?: boolean; + poster_url?: string; /** Restrict resolution to these libraries; omitted/empty = entire catalog the user can see. */ library_ids?: number[]; } diff --git a/web/src/components/CollectionTemplateGallery/CollectionTemplateCard.test.tsx b/web/src/components/CollectionTemplateGallery/CollectionTemplateCard.test.tsx index c72b2144..23872b03 100644 --- a/web/src/components/CollectionTemplateGallery/CollectionTemplateCard.test.tsx +++ b/web/src/components/CollectionTemplateGallery/CollectionTemplateCard.test.tsx @@ -21,7 +21,10 @@ const baseTemplate: CollectionTemplate = { describe("CollectionTemplateCard", () => { it("shows the template title, description, source badge, and media kind", () => { const markup = renderToStaticMarkup( - {}} />, + {}} + />, ); expect(markup).toContain("Trending Movies This Week"); @@ -29,6 +32,7 @@ describe("CollectionTemplateCard", () => { expect(markup).toContain("TMDB"); expect(markup).toContain("Movies"); expect(markup).toContain("syncs daily"); + expect(markup).not.toContain(" { diff --git a/web/src/components/CollectionTemplateGallery/CollectionTemplateCard.tsx b/web/src/components/CollectionTemplateGallery/CollectionTemplateCard.tsx index 68ef34e5..2ebdfad7 100644 --- a/web/src/components/CollectionTemplateGallery/CollectionTemplateCard.tsx +++ b/web/src/components/CollectionTemplateGallery/CollectionTemplateCard.tsx @@ -19,34 +19,20 @@ export function CollectionTemplateCard({ template, onPick }: Props) { @@ -594,8 +601,13 @@ function buildFeaturedBundleRequest( function BundleApplyResult({ result }: { result: ApplyCollectionTemplateBundleResponse }) { const actionLabel = result.dry_run ? "Would create" : "Created"; const deleteActionLabel = result.dry_run ? "Would delete" : "Deleted"; + const created = result.created ?? []; + const skipped = result.skipped ?? []; + const failed = result.failed ?? []; + const deleted = result.deleted ?? []; const deleteFailed = result.delete_failed ?? []; const deleteSkipped = result.delete_skipped ?? []; + const syncQueued = result.sync_queued ?? []; const featured = result.featured ?? []; const featuredFailed = result.featured_failed ?? []; return ( @@ -603,13 +615,20 @@ function BundleApplyResult({ result }: { result: ApplyCollectionTemplateBundleRe
{result.delete_existing ? ( <> - {deleteActionLabel} {result.deleted.length}; delete skipped {deleteSkipped.length}; - delete failed {deleteFailed.length};{" "} + {deleteActionLabel} {deleted.length}; delete skipped {deleteSkipped.length}; delete + failed {deleteFailed.length};{" "} ) : null} - {actionLabel} {result.created.length}; skipped {result.skipped.length}; failed{" "} - {result.failed.length}; featured {featured.length}; featured failed {featuredFailed.length} + {actionLabel} {created.length}; skipped {skipped.length}; failed {failed.length} + {syncQueued.length > 0 ? `; sync queued ${syncQueued.length}` : ""}; featured{" "} + {featured.length}; featured failed {featuredFailed.length}
+ {syncQueued.length > 0 ? ( +
+ Initial syncs are running in the background. Collections are available now; items appear + as each sync finishes. +
+ ) : null} {deleteFailed.length > 0 ? (
{deleteFailed.slice(0, 5).map((entry) => ( @@ -620,9 +639,9 @@ function BundleApplyResult({ result }: { result: ApplyCollectionTemplateBundleRe ))}
) : null} - {result.failed.length > 0 ? ( + {failed.length > 0 ? (
- {result.failed.slice(0, 5).map((entry) => ( + {failed.slice(0, 5).map((entry) => (
{entry.library_name} / {entry.template_title}: {entry.reason ?? "failed"}
diff --git a/web/src/components/CollectionTemplateGallery/TemplatePosterField.tsx b/web/src/components/CollectionTemplateGallery/TemplatePosterField.tsx new file mode 100644 index 00000000..79b0ed17 --- /dev/null +++ b/web/src/components/CollectionTemplateGallery/TemplatePosterField.tsx @@ -0,0 +1,101 @@ +import { Check } from "lucide-react"; + +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { cn } from "@/lib/utils"; +import type { CollectionTemplate } from "@/lib/collectionTemplates"; + +export type TemplatePosterMode = "default" | "custom"; + +interface TemplatePosterFieldProps { + template: CollectionTemplate; + mode: TemplatePosterMode; + onModeChange: (mode: TemplatePosterMode) => void; + customUrl: string; + onCustomUrlChange: (value: string) => void; + inputId: string; +} + +export function TemplatePosterField({ + template, + mode, + onModeChange, + customUrl, + onCustomUrlChange, + inputId, +}: TemplatePosterFieldProps) { + const hasDefaultPoster = Boolean(template.poster_path); + + return ( +
+ +
+ {hasDefaultPoster ? ( +
+ onModeChange("default")} + /> + onModeChange("custom")} + /> +
+ ) : null} + + {mode === "default" && template.poster_path ? ( +
+ +
+

Server default

+

{template.poster_path}

+
+
+ ) : ( +
+ onCustomUrlChange(event.target.value)} + placeholder="https://example.com/poster.jpg" + /> +
+ )} +
+
+ ); +} + +function PosterChoiceButton({ + label, + active, + onClick, +}: { + label: string; + active: boolean; + onClick: () => void; +}) { + return ( + + ); +} diff --git a/web/src/components/CollectionTemplateGallery/UserCollectionTemplateConfigForm.tsx b/web/src/components/CollectionTemplateGallery/UserCollectionTemplateConfigForm.tsx index 61c51080..b810c99f 100644 --- a/web/src/components/CollectionTemplateGallery/UserCollectionTemplateConfigForm.tsx +++ b/web/src/components/CollectionTemplateGallery/UserCollectionTemplateConfigForm.tsx @@ -34,6 +34,7 @@ import { import { Switch } from "@/components/ui/switch"; import { MDBListBrowser } from "./MDBListBrowser"; +import { TemplatePosterField, type TemplatePosterMode } from "./TemplatePosterField"; interface Props { template: CollectionTemplate; @@ -89,6 +90,10 @@ export function UserCollectionTemplateConfigForm({ template, onCancel, onCreated const [isShared, setIsShared] = useState(false); const [mdblistUrl, setMdblistUrl] = useState(template.mdblist?.url ?? ""); const [libraryIds, setLibraryIds] = useState([]); + const [posterMode, setPosterMode] = useState(() => + template.poster_path ? "default" : "custom", + ); + const [customPosterUrl, setCustomPosterUrl] = useState(""); const eligibility = libraryEligibilityForMediaKind(template.media_kind); const pickerLibraries = libraries.map((lib) => ({ @@ -113,6 +118,10 @@ export function UserCollectionTemplateConfigForm({ template, onCancel, onCreated sync_schedule: scheduleChoiceToRequest(schedule), limit: parsedLimit, is_shared: isShared, + poster_url: + posterMode === "custom" + ? customPosterUrl.trim() || undefined + : template.poster_path || undefined, library_ids: libraryIds.length > 0 ? libraryIds : undefined, }; @@ -236,6 +245,15 @@ export function UserCollectionTemplateConfigForm({ template, onCancel, onCreated

) : null} + +
diff --git a/web/src/hooks/queries/admin/collections.ts b/web/src/hooks/queries/admin/collections.ts index 17f9e5d8..a66e7dc5 100644 --- a/web/src/hooks/queries/admin/collections.ts +++ b/web/src/hooks/queries/admin/collections.ts @@ -1,6 +1,6 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; -import { api } from "@/api/client"; +import { ApiClientError, api } from "@/api/client"; import type { CreateLibraryCollectionRequest, ImportMDBListCollectionRequest, @@ -24,6 +24,22 @@ import { invalidateAdminCollectionQueries } from "../collectionSurfaceRefresh"; const ADMIN_STALE_TIME = 30_000; +function isLikelyRequestTimeout(error: unknown): boolean { + if (error instanceof ApiClientError) { + return ( + error.status === 408 || error.status === 502 || error.status === 503 || error.status === 504 + ); + } + return error instanceof TypeError; +} + +function applyTemplateBundleErrorMessage(error: unknown): string { + if (isLikelyRequestTimeout(error)) { + return "The apply request timed out. Silo may still be creating collections; refresh in a minute."; + } + return error instanceof Error ? error.message : "Failed to apply defaults"; +} + function buildCollectionFormData( data: Record, poster?: File | null, @@ -123,13 +139,15 @@ export function useApplyCollectionTemplateBundle() { if (!result.dry_run) { const created = result.created.length; const deleted = result.deleted?.length ?? 0; + const syncQueued = result.sync_queued?.length ?? 0; const failed = result.failed.length; const deleteFailed = result.delete_failed?.length ?? 0; const failureCount = failed + deleteFailed; - if (created > 0 || deleted > 0) { + if (created > 0 || deleted > 0 || syncQueued > 0) { const message = [ deleted > 0 ? `Deleted ${deleted}` : "", created > 0 ? `created ${created}` : "", + syncQueued > 0 ? `queued ${syncQueued} syncs` : "", failureCount > 0 ? `${failureCount} failed` : "", ] .filter(Boolean) @@ -141,7 +159,7 @@ export function useApplyCollectionTemplateBundle() { } }, onError: (error) => { - toast.error(error instanceof Error ? error.message : "Failed to apply defaults"); + toast.error(applyTemplateBundleErrorMessage(error)); }, }); } diff --git a/web/src/lib/collectionTemplates.ts b/web/src/lib/collectionTemplates.ts index 918b4c86..d9d0a239 100644 --- a/web/src/lib/collectionTemplates.ts +++ b/web/src/lib/collectionTemplates.ts @@ -162,6 +162,7 @@ export interface ApplyCollectionTemplateBundleResponse { created: CollectionTemplateBundleApplyEntry[]; skipped: CollectionTemplateBundleApplyEntry[]; failed: CollectionTemplateBundleApplyEntry[]; + sync_queued?: CollectionTemplateBundleApplyEntry[]; featured: CollectionTemplateBundleFeaturedEntry[]; featured_failed: CollectionTemplateBundleFeaturedEntry[]; } diff --git a/web/src/pages/adminCollectionsShared.tsx b/web/src/pages/adminCollectionsShared.tsx index 6f6a1ac2..93433f17 100644 --- a/web/src/pages/adminCollectionsShared.tsx +++ b/web/src/pages/adminCollectionsShared.tsx @@ -816,7 +816,7 @@ export function TMDBPresetForm({ id="tmdb-limit" type="number" min={1} - max={100} + max={200} step={1} inputMode="numeric" value={limit} @@ -1041,7 +1041,7 @@ export function TraktPresetForm({ id="trakt-limit" type="number" min={1} - max={100} + max={200} step={1} inputMode="numeric" value={limit} @@ -1589,7 +1589,7 @@ export function CollectionEditForm({ id="collection-tmdb-limit" type="number" min={1} - max={100} + max={200} step={1} inputMode="numeric" value={tmdbLimit} @@ -1653,7 +1653,7 @@ export function CollectionEditForm({ id="collection-trakt-limit" type="number" min={1} - max={100} + max={200} step={1} inputMode="numeric" value={traktLimit}