fix(collections): repair broken builtin collection templates (#331)

* fix(collections): repair broken builtin collection templates

A live audit of the builtin template catalog (all 40 MDBList URLs and all
10 TMDB franchise IDs fetched) found two dead sources, a silent bundle-apply
collision, and several templates whose defaults contradict their descriptions:

- Repoint mdblist_misc_a24 and mdblist_misc_criterion_collection to live
  lists; the original irvingbeano/shtluck lists were deleted on MDBList
  (404), so every sync of those collections failed.
- Retitle mdblist_charts_popular_movies to "IMDb MovieMeter Top 100". It
  shared the "popular-movies" title slug with tmdb_popular_movies, and
  bundle apply dedupes by slug per library, so applying all_defaults
  silently skipped it. Poster regenerated from the raw plate with the new
  title; new handler test asserts builtin title slugs stay unique.
- Raise the shared default limit 50 -> 100, give the IMDb Top 250 templates
  an explicit 250 (limit*4 fetch trim previously never scanned entries
  201-250), and drop the limit on catalog lists (Criterion, A24) so they
  hold every owned title.
- Correct IFC Films to MediaMovie (live list is 100% movies; as MediaMixed
  it was offered to TV libraries where it always synced empty) and fix the
  Trakt Popular descriptions (ratings-based, not "most-watched").
- Update stale limit docs in collection-templates.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(collections): raise import limit caps above IMDb Top 250 default

The IMDb Top 250 templates now default to 250 items, but the template
config forms rendered their Max Items input with max=200 and the user
import API rejected limits above 200, so applying those templates from
the direct galleries failed native validation or got a 400.

Raise the cap to 500 on both sides, wired to shared constants: sync's
fetch trim (collectionSourceFetchMax) never scans more than 500 source
entries, so a larger explicit limit could never be satisfied anyway.
collectionutil.MaxExplicitItemLimit backs validateOptionalLimit, and
COLLECTION_MAX_ITEMS in lib/collectionTemplates backs all seven Max
Items inputs (gallery forms + admin import/editor dialogs).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Quick
2026-07-07 10:46:50 -04:00
committed by GitHub
co-authored by Claude Fable 5
parent a26b2de454
commit 43d9056b01
11 changed files with 113 additions and 45 deletions
+4 -3
View File
@@ -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
@@ -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{
@@ -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
+40 -27
View File
@@ -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",
@@ -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)
}
}
}
@@ -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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 454 KiB

After

Width:  |  Height:  |  Size: 421 KiB

@@ -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}
@@ -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}
+5
View File
@@ -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<CollectionTemplateCatalog> {
return api<CollectionTemplateCatalog>("/admin/collections/templates");
}
+10 -6
View File
@@ -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}