* feat(sections): fix broken home section templates and add six new ones Fixes templates that silently produced nothing: - award_winners: hide from gallery (resolver is a stub until award data exists); saved sections keep resolving - seasonal_themed: christmas/st_patricks/thanksgiving get an interim title-keyword resolver, and multi-theme selection skips themes without an executable query so a data-less theme can no longer black out the section during its own window (previously killed the section all of December) - taste_match: empty genre now auto-picks the profile's strongest taste cluster (fallback: server top genre); the default preset was permanently empty - because_you_watched: honor the recipe's anchor_item_id key (fetcher only read legacy source_item_id, so pinning an anchor did nothing) - editorial_spotlight: reject subject_type=franchise (validated but could never resolve); fix drawer misrepresenting pinned presets as auto-rotate - admin_curated_list: add a catalog-search item picker so Editor's Picks is actually addable; block saving an empty list; hide admin_only recipes from profile-facing galleries - discovery fetchers (hidden_gems, forgotten_favorites, critically_acclaimed): honor single/multi library scope, intersected with viewer access; implement hidden_gems max_play_count New templates: returning_shows (new season of shows you've watched), genre_roulette (rotating top-genre spotlight with title override), anniversaries (milestone release anniversaries this month), short_watches (well-rated movies under a runtime cap), family_movie_night seasonal theme (Fri/Sat evenings), and a "New in 4K" format_showcase preset via a new sort=recent param. Adds a blanket test asserting every visible gallery preset's defaults pass its own recipe validation — the gap that let taste_match and Editor's Picks ship broken. New SQL shapes validated with EXPLAIN against the dev database. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(sections): address PR #332 review findings Codex review: - returning_shows: the new-season file check now applies the effective library scope (section scope ∩ viewer-allowed, minus disabled) to media_files.media_folder_id, so an episode file that only exists in an out-of-scope folder can no longer surface the series - buildLibraryScope: replaced the media_item_libraries row join with EXISTS / NOT EXISTS semi-joins. An item in several in-scope libraries now yields exactly one row in the non-GROUP BY rails (short_watches, anniversaries, seasonal keyword, format_showcase, new_to_library, ...), and the disabled-library check is item-level, closing the join-row leak where membership in an allowed library masked membership in a disabled one. Deny-only mode keeps the positive-membership guard, mirroring catalog's appendDiscoveryLibraryScope. CodeRabbit review: - recommendations reader: a taste cluster whose cached items are entirely filtered out now falls through to the next cluster / global fallback instead of returning an empty row - genre_roulette: multi-library scopes get distinct rotation seeds - returning_shows: reject negative lookback_days at validation - shared oneOf() enum validator replaces per-recipe switch duplication - SeasonalTitleOverride usable-filter contract covered by a direct test - web NumberParamField: integer-only guard + step=1 (backend fields are Go ints; fractional values failed unmarshalling at save) - curated list picker: search failures show an error instead of a misleading "No matches."; pre-existing item_ids hydrate display titles via the watch-detail endpoint instead of rendering raw ids Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
61 lines
2.0 KiB
Go
61 lines
2.0 KiB
Go
package sections
|
|
|
|
import (
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/catalog"
|
|
)
|
|
|
|
func TestBuildRecentlyAddedQueryUsesLibraryMembershipFastPathForSingleLibrary(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
query, args := buildRecentlyAddedQuery(ResolvedSection{
|
|
ItemLimit: 12,
|
|
Config: json.RawMessage(`{"generated_source":"home_library_recent","filter_library_id":1,"filter_type":"movie"}`),
|
|
}, nil, []int{1, 2}, catalog.AccessFilter{MaxContentRating: "PG-13"})
|
|
|
|
for _, want := range []string{
|
|
"FROM media_item_libraries mil JOIN media_items mi ON mi.content_id = mil.content_id",
|
|
"mil.media_folder_id = $1",
|
|
"mil.media_folder_id IN ($2, $3)",
|
|
"mi.type = $4",
|
|
"ORDER BY mil.first_seen_at DESC, mil.content_id ASC",
|
|
} {
|
|
if !strings.Contains(query, want) {
|
|
t.Fatalf("query missing %q:\n%s", want, query)
|
|
}
|
|
}
|
|
if strings.Contains(query, "ORDER BY mi.created_at DESC") {
|
|
t.Fatalf("single-library fast path should not order by media_items.created_at:\n%s", query)
|
|
}
|
|
if got, want := args[len(args)-1], 12; got != want {
|
|
t.Fatalf("limit arg = %v, want %v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestBuildRecentlyAddedQueryKeepsGenericPathForMultiLibraryConfig(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
query, _ := buildRecentlyAddedQuery(ResolvedSection{
|
|
ItemLimit: 12,
|
|
Config: json.RawMessage(`{"filter_library_ids":[1,2],"filter_type":"movie"}`),
|
|
}, nil, nil, catalog.AccessFilter{})
|
|
|
|
for _, want := range []string{
|
|
"FROM media_items mi",
|
|
// Library scope is a semi-join so an item in several selected
|
|
// libraries yields one row (see buildLibraryScope).
|
|
"EXISTS (SELECT 1 FROM media_item_libraries mil_scope_in WHERE mil_scope_in.content_id = mi.content_id AND mil_scope_in.media_folder_id = ANY($2))",
|
|
"ORDER BY mi.created_at DESC, mi.content_id ASC",
|
|
} {
|
|
if !strings.Contains(query, want) {
|
|
t.Fatalf("query missing %q:\n%s", want, query)
|
|
}
|
|
}
|
|
if strings.Contains(query, "ORDER BY mil.first_seen_at DESC") {
|
|
t.Fatalf("multi-library generic path should not use the single-library first_seen_at sort:\n%s", query)
|
|
}
|
|
}
|