diff --git a/docs/wiki/admin/collection-templates.md b/docs/wiki/admin/collection-templates.md index e5980145..047a10fd 100644 --- a/docs/wiki/admin/collection-templates.md +++ b/docs/wiki/admin/collection-templates.md @@ -63,9 +63,10 @@ The default registry covers TMDB and Trakt's public discovery feeds plus a | Editorial | Trakt Recommended Movies/Shows (per profile), Mindfuck Movies, Top Documentary Movies, Top Horror Movies (MDBList) | | Custom | MDBList — bring your own JSON URL | -TMDB and Trakt templates default to 20 items; MDBList templates default to 50 -because the underlying lists tend to be longer (IMDb Top 250, full streaming -catalogs, etc.). Every template ships with a conservative sync cadence — every +Templates default to 100 items. Finite canonical lists override that so the +collection can hold what the title promises: the IMDb Top 250 templates use +250, and catalog lists (Criterion Collection, A24) ship with no limit at all +so they match every owned title. Every template ships with a conservative sync cadence — every 6 hours for trending, daily for popular and streaming services, weekly for top-rated and editorial picks — and a "featured" hint where appropriate. All defaults are editable in the small confirmation drawer before the collection diff --git a/internal/api/handlers/collection_templates_test.go b/internal/api/handlers/collection_templates_test.go index 1c382ab4..cd6d843d 100644 --- a/internal/api/handlers/collection_templates_test.go +++ b/internal/api/handlers/collection_templates_test.go @@ -36,6 +36,22 @@ func TestCollectionTemplateHandlerReturnsBuiltinCatalog(t *testing.T) { } } +// Bundle apply dedupes collections by slugified title per library +// (applyTemplateBundle's slug-match adoption), so two builtin templates with +// the same title slug can never coexist: whichever applies second is silently +// skipped as already_exists. +func TestBuiltinTemplateTitleSlugsAreUnique(t *testing.T) { + bySlug := make(map[string]string) + for _, tmpl := range templates.List() { + slug := slugifyCollectionName(tmpl.Title) + if other, exists := bySlug[slug]; exists { + t.Errorf("templates %q and %q share title slug %q; bundle apply would silently skip one", other, tmpl.ID, slug) + continue + } + bySlug[slug] = tmpl.ID + } +} + func TestCollectionTemplateHandlerHonoursInjectedRegistry(t *testing.T) { registry := templates.NewRegistry() registry.Register(templates.Template{ diff --git a/internal/api/handlers/user_collection_imports.go b/internal/api/handlers/user_collection_imports.go index 76773016..f5bb336c 100644 --- a/internal/api/handlers/user_collection_imports.go +++ b/internal/api/handlers/user_collection_imports.go @@ -16,6 +16,7 @@ import ( apimw "github.com/Silo-Server/silo-server/internal/api/middleware" "github.com/Silo-Server/silo-server/internal/catalog" "github.com/Silo-Server/silo-server/internal/collections/templates" + "github.com/Silo-Server/silo-server/internal/collectionutil" "github.com/Silo-Server/silo-server/internal/mdblist" "github.com/Silo-Server/silo-server/internal/s3client" "github.com/Silo-Server/silo-server/internal/usercollections" @@ -430,8 +431,9 @@ func validateOptionalLimit(limit *int, w http.ResponseWriter) bool { if limit == nil { return true } - if *limit <= 0 || *limit > 200 { - writeError(w, http.StatusBadRequest, "bad_request", "limit must be between 1 and 200") + if *limit <= 0 || *limit > collectionutil.MaxExplicitItemLimit { + writeError(w, http.StatusBadRequest, "bad_request", + fmt.Sprintf("limit must be between 1 and %d", collectionutil.MaxExplicitItemLimit)) return false } return true diff --git a/internal/collections/templates/builtin.go b/internal/collections/templates/builtin.go index 48053221..edc9b486 100644 --- a/internal/collections/templates/builtin.go +++ b/internal/collections/templates/builtin.go @@ -8,7 +8,12 @@ package templates // Sync cadences favour conservative defaults — a heavy library does not need // "Trending" to refresh more than every six hours, and TMDB rate-limits make // hourly refreshes wasteful for shared servers. -const builtinDefaultLimit = 50 +// +// builtinDefaultLimit caps the matched items per collection. Sync scans up to +// 4× the limit from the source list (see collectionutil.SourceFetchLimit), so +// finite canonical lists (IMDb Top 250, Criterion) carry explicit larger +// limits — the shared default would silently truncate them. +const builtinDefaultLimit = 100 var builtinTemplates = []Template{ // ── Trending (TMDB) ───────────────────────────────────────────────────── @@ -105,7 +110,7 @@ var builtinTemplates = []Template{ { ID: "trakt_popular_movies", Title: "Trakt Popular Movies", - Description: "Trakt's all-time most-watched movies.", + Description: "Trakt's most popular movies of all time, ranked by user ratings.", Icon: "🌟", Category: CategoryPopular, Source: SourceTrakt, @@ -117,7 +122,7 @@ var builtinTemplates = []Template{ { ID: "trakt_popular_shows", Title: "Trakt Popular Shows", - Description: "Trakt's all-time most-watched shows.", + Description: "Trakt's most popular shows of all time, ranked by user ratings.", Icon: "🌟", Category: CategoryPopular, Source: SourceTrakt, @@ -346,7 +351,7 @@ var builtinTemplates = []Template{ Category: CategoryTopRated, Source: SourceMDBList, MediaKind: MediaMovie, - DefaultLimit: builtinDefaultLimit, + DefaultLimit: 250, DefaultSyncSchedule: "0 5 * * 0", MDBList: &MDBListSpec{URL: "https://mdblist.com/lists/snoak/imdb-top-250-movies/json"}, }, @@ -358,7 +363,7 @@ var builtinTemplates = []Template{ Category: CategoryTopRated, Source: SourceMDBList, MediaKind: MediaTV, - DefaultLimit: builtinDefaultLimit, + DefaultLimit: 250, DefaultSyncSchedule: "0 5 * * 0", MDBList: &MDBListSpec{URL: "https://mdblist.com/lists/snoak/imdb-top-250-shows/json"}, }, @@ -600,10 +605,13 @@ var builtinTemplates = []Template{ // ── Charts (MDBList) ─────────────────────────────────────────────────── // 1000s band. "Top Rated Movies" is already covered by the existing // `mdblist_imdb_top_250_movies` template under CategoryTopRated, so this - // chart only contributes a Popular-Movies entry. + // chart only contributes the MovieMeter entry. Its title must NOT be + // "Popular Movies": bundle apply dedupes by slugified title per library, + // and `tmdb_popular_movies` already owns the popular-movies slug — a + // duplicate title would make this template silently skip in all_defaults. { ID: "mdblist_charts_popular_movies", - Title: "Popular Movies", + Title: "IMDb MovieMeter Top 100", Description: "IMDb MovieMeter's most-popular movies right now, kept in sync from MDBList (linaspurinis).", Icon: "📊", Category: CategoryPopular, @@ -756,41 +764,46 @@ var builtinTemplates = []Template{ // 9000s band. Studio and distributor catalogs the operator's PMM // `movies_misc` set typically includes. { - ID: "mdblist_misc_criterion_collection", - Title: "Criterion Collection", - Description: "The complete Criterion Collection of films, kept in sync from MDBList (shtluck).", - Icon: "🎞️", - Category: CategoryEditorial, - Source: SourceMDBList, - MediaKind: MediaMovie, - DefaultLimit: builtinDefaultLimit, + // Repointed 2026-07: the original shtluck list was deleted on MDBList. + ID: "mdblist_misc_criterion_collection", + Title: "Criterion Collection", + Description: "The Criterion Collection of films, kept in sync from MDBList (reptauros).", + Icon: "🎞️", + Category: CategoryEditorial, + Source: SourceMDBList, + MediaKind: MediaMovie, + // No limit: this is a catalog list (~1000 entries) — the collection + // should hold every Criterion title the library owns, not a top-N. + DefaultLimit: 0, DefaultSortOrder: 9001, DefaultSyncSchedule: "0 5 * * 0", Tags: []string{"editorial", "studio"}, - MDBList: &MDBListSpec{URL: "https://mdblist.com/lists/shtluck/the-complete-criterion-collection/json"}, + MDBList: &MDBListSpec{URL: "https://mdblist.com/lists/reptauros/the-criterion-collection/json"}, }, { - ID: "mdblist_misc_a24", - Title: "A24", - Description: "Films distributed by A24, kept in sync from MDBList (irvingbeano).", - Icon: "🎬", - Category: CategoryEditorial, - Source: SourceMDBList, - MediaKind: MediaMovie, - DefaultLimit: builtinDefaultLimit, + // Repointed 2026-07: the original irvingbeano list was deleted on MDBList. + ID: "mdblist_misc_a24", + Title: "A24", + Description: "Films distributed by A24, kept in sync from MDBList (InShaneMagic).", + Icon: "🎬", + Category: CategoryEditorial, + Source: SourceMDBList, + MediaKind: MediaMovie, + // No limit: catalog list (~150 entries) — hold every owned A24 film. + DefaultLimit: 0, DefaultSortOrder: 9002, DefaultSyncSchedule: "0 5 * * 0", Tags: []string{"editorial", "studio"}, - MDBList: &MDBListSpec{URL: "https://mdblist.com/lists/irvingbeano/a24/json"}, + MDBList: &MDBListSpec{URL: "https://mdblist.com/lists/inshanemagic/a24-films/json"}, }, { ID: "mdblist_misc_ifc_films", Title: "IFC Films", - Description: "IFC Films catalog spanning movies and series (MDBList by notamongo5).", + Description: "IFC Films movie catalog (MDBList by notamongo5).", Icon: "🎬", Category: CategoryEditorial, Source: SourceMDBList, - MediaKind: MediaMixed, + MediaKind: MediaMovie, DefaultLimit: builtinDefaultLimit, DefaultSortOrder: 9003, DefaultSyncSchedule: "0 5 * * 0", diff --git a/internal/collections/templates/templates_test.go b/internal/collections/templates/templates_test.go index 890102a8..0c52f477 100644 --- a/internal/collections/templates/templates_test.go +++ b/internal/collections/templates/templates_test.go @@ -62,10 +62,23 @@ func TestBuiltinTemplatesValidate(t *testing.T) { } } -func TestBuiltinTemplatesDefaultToFiftyItems(t *testing.T) { +func TestBuiltinTemplateDefaultLimits(t *testing.T) { + // Finite canonical lists override the shared default: top-N truncation + // would contradict what their titles promise. Catalog lists (Criterion, + // A24) carry no limit at all so the collection holds every owned title. + overrides := map[string]int{ + "mdblist_imdb_top_250_movies": 250, + "mdblist_imdb_top_250_shows": 250, + "mdblist_misc_criterion_collection": 0, + "mdblist_misc_a24": 0, + } for _, tmpl := range List() { - if tmpl.DefaultLimit != builtinDefaultLimit { - t.Errorf("template %q default_limit = %d, want %d", tmpl.ID, tmpl.DefaultLimit, builtinDefaultLimit) + want := builtinDefaultLimit + if override, ok := overrides[tmpl.ID]; ok { + want = override + } + if tmpl.DefaultLimit != want { + t.Errorf("template %q default_limit = %d, want %d", tmpl.ID, tmpl.DefaultLimit, want) } } } diff --git a/internal/collectionutil/collectionutil.go b/internal/collectionutil/collectionutil.go index 998e6a1b..6500948e 100644 --- a/internal/collectionutil/collectionutil.go +++ b/internal/collectionutil/collectionutil.go @@ -13,6 +13,12 @@ const ( collectionSourceFetchMax = 500 ) +// MaxExplicitItemLimit is the largest explicit per-collection item limit the +// import APIs accept. Sync never scans more than collectionSourceFetchMax +// source entries, so a larger explicit limit could never be satisfied anyway. +// Mirrored by COLLECTION_MAX_ITEMS in web/src/lib/collectionTemplates.ts. +const MaxExplicitItemLimit = collectionSourceFetchMax + func SourceFetchLimit(itemLimit *int) int { if itemLimit == nil || *itemLimit <= 0 { return 0 diff --git a/web/public/images/collection-templates/mdblist_charts_popular_movies.jpg b/web/public/images/collection-templates/mdblist_charts_popular_movies.jpg index c724855d..ebcb9b8e 100644 Binary files a/web/public/images/collection-templates/mdblist_charts_popular_movies.jpg and b/web/public/images/collection-templates/mdblist_charts_popular_movies.jpg differ diff --git a/web/src/components/CollectionTemplateGallery/CollectionTemplateConfigForm.tsx b/web/src/components/CollectionTemplateGallery/CollectionTemplateConfigForm.tsx index 5d0d5d79..f77d5d36 100644 --- a/web/src/components/CollectionTemplateGallery/CollectionTemplateConfigForm.tsx +++ b/web/src/components/CollectionTemplateGallery/CollectionTemplateConfigForm.tsx @@ -8,7 +8,11 @@ import { } from "@/hooks/queries/admin/collections"; import { useProfiles } from "@/hooks/queries/profiles"; import type { Library } from "@/api/types"; -import { libraryEligibilityForMediaKind, mediaKindLabel } from "@/lib/collectionTemplates"; +import { + COLLECTION_MAX_ITEMS, + libraryEligibilityForMediaKind, + mediaKindLabel, +} from "@/lib/collectionTemplates"; import type { CollectionTemplate, LibraryEligibility } from "@/lib/collectionTemplates"; import { CollectionLibraryPicker, @@ -308,7 +312,7 @@ export function CollectionTemplateConfigForm({ id="template-limit" type="number" min={1} - max={200} + max={COLLECTION_MAX_ITEMS} step={1} inputMode="numeric" value={limit} diff --git a/web/src/components/CollectionTemplateGallery/UserCollectionTemplateConfigForm.tsx b/web/src/components/CollectionTemplateGallery/UserCollectionTemplateConfigForm.tsx index 1dd313ff..76c09319 100644 --- a/web/src/components/CollectionTemplateGallery/UserCollectionTemplateConfigForm.tsx +++ b/web/src/components/CollectionTemplateGallery/UserCollectionTemplateConfigForm.tsx @@ -20,7 +20,11 @@ import { COLLECTION_WATCH_FILTER_OPTIONS, displayFiltersToQueryDefinition, } from "@/lib/collectionDisplayFilters"; -import { libraryEligibilityForMediaKind, mediaKindLabel } from "@/lib/collectionTemplates"; +import { + COLLECTION_MAX_ITEMS, + libraryEligibilityForMediaKind, + mediaKindLabel, +} from "@/lib/collectionTemplates"; import type { CollectionTemplate } from "@/lib/collectionTemplates"; import { CollectionLibraryPicker, @@ -313,7 +317,7 @@ export function UserCollectionTemplateConfigForm({ template, onCancel, onCreated id="user-template-limit" type="number" min={1} - max={200} + max={COLLECTION_MAX_ITEMS} step={1} inputMode="numeric" value={limit} diff --git a/web/src/lib/collectionTemplates.ts b/web/src/lib/collectionTemplates.ts index 230ab8e1..b7ba699f 100644 --- a/web/src/lib/collectionTemplates.ts +++ b/web/src/lib/collectionTemplates.ts @@ -175,6 +175,11 @@ export interface ApplyCollectionTemplateBundleResponse { export const TEMPLATE_STALE_TIME = 5 * 60_000; +// Largest explicit "Max Items" value the import APIs accept. Mirrors +// MaxExplicitItemLimit in internal/collectionutil — sync never scans more +// than 500 source entries, so larger limits could never be satisfied. +export const COLLECTION_MAX_ITEMS = 500; + export function fetchCollectionTemplates(): Promise { return api("/admin/collections/templates"); } diff --git a/web/src/pages/adminCollectionsShared.tsx b/web/src/pages/adminCollectionsShared.tsx index 1ccc0ef8..97e3e576 100644 --- a/web/src/pages/adminCollectionsShared.tsx +++ b/web/src/pages/adminCollectionsShared.tsx @@ -2,7 +2,11 @@ import { useEffect, useState } from "react"; import type { FormEvent, ReactNode } from "react"; import type { CreateLibraryCollectionRequest, Library, LibraryCollection } from "@/api/types"; import { normalizeQueryDefinition } from "@/api/types"; -import { libraryEligibilityForMediaKind, type LibraryEligibility } from "@/lib/collectionTemplates"; +import { + COLLECTION_MAX_ITEMS, + libraryEligibilityForMediaKind, + type LibraryEligibility, +} from "@/lib/collectionTemplates"; import { useCreateAdminCollection, useDeleteCollectionImage, @@ -852,7 +856,7 @@ export function TMDBPresetForm({ id="tmdb-limit" type="number" min={1} - max={200} + max={COLLECTION_MAX_ITEMS} step={1} inputMode="numeric" value={limit} @@ -1123,7 +1127,7 @@ export function TraktPresetForm({ id="trakt-limit" type="number" min={1} - max={200} + max={COLLECTION_MAX_ITEMS} step={1} inputMode="numeric" value={limit} @@ -1688,7 +1692,7 @@ export function CollectionEditForm({ id="collection-tmdb-limit" type="number" min={1} - max={200} + max={COLLECTION_MAX_ITEMS} step={1} inputMode="numeric" value={tmdbLimit} @@ -1741,7 +1745,7 @@ export function CollectionEditForm({ id="collection-trakt-limit" type="number" min={1} - max={200} + max={COLLECTION_MAX_ITEMS} step={1} inputMode="numeric" value={traktLimit} @@ -1806,7 +1810,7 @@ export function CollectionEditForm({ id="collection-trakt-limit" type="number" min={1} - max={200} + max={COLLECTION_MAX_ITEMS} step={1} inputMode="numeric" value={traktLimit}