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:
committed by
GitHub
co-authored by
Claude Fable 5
parent
a26b2de454
commit
43d9056b01
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user