feat(sections): add trending_discover home section

A library-agnostic home section that surfaces external global trending
(TMDB or Trakt, admin-selectable) mixing movies + series, matched to
titles in the viewer's enabled libraries. TMDB uses /trending/all/{window}
(natively mixed); Trakt merges trending movies + shows. Fetched live with
a 1h in-process cache, so no background job or stored collection — and no
per-library duplication.

Appears in the admin section gallery via its recipe presets (TMDB Trending
Today/This Week, Trakt Trending); featured -> hero via the existing flag.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Quick
2026-05-29 09:25:17 -04:00
co-authored by Claude Opus 4.8
parent c216899c2f
commit ba4fd9d9fd
5 changed files with 372 additions and 3 deletions
+6
View File
@@ -878,6 +878,12 @@ func NewRouter(deps Dependencies) chi.Router {
}
}
// Wire external-trending fetchers into the section fetcher so the
// trending_discover home section can pull TMDB/Trakt trending.
sectionFetcher.ItemRepo = itemRepo
sectionFetcher.TMDBTrending = libraryCollectionService.TMDBCollections
sectionFetcher.TraktTrending = libraryCollectionService.TraktCollections
libraryCollectionHandler = handlers.NewLibraryCollectionHandler(
libraryCollectionRepo,
libraryCollectionService,
+235 -3
View File
@@ -67,9 +67,18 @@ type Fetcher struct {
RecommendationRepo *recommendations.Repo // retained for non-reader call sites
RecommendationReader recommendationReader
NextUpRepo *catalog.NextUpRepository
candidateCacheMu sync.Mutex
candidateCache *editorialCandidateCache
candidateGroup singleflight.Group
// ItemRepo resolves external IDs (TMDB/Trakt) to library content IDs for
// the trending_discover section. Nil disables external-trending matching.
ItemRepo *catalog.ItemRepository
// TMDBTrending and TraktTrending fetch external global trending lists. Each
// is nil when that provider is not configured.
TMDBTrending catalog.TMDBCollectionFetcher
TraktTrending catalog.TraktCollectionFetcher
candidateCacheMu sync.Mutex
candidateCache *editorialCandidateCache
candidateGroup singleflight.Group
// Clock returns the current time. Defaults to recipes.RealClock{}.
// Tests inject recipes.FixedClock for deterministic seasonal/editorial behavior.
@@ -1070,6 +1079,8 @@ func (f *Fetcher) fetchSection(ctx context.Context, s ResolvedSection, libraryID
return f.fetchNewToLibrary(ctx, s, libraryID, libraryIDs, filter)
case SectionMostWatched:
return f.fetchMostWatched(ctx, s, libraryID, libraryIDs, filter)
case SectionTrendingDiscover:
return f.fetchTrendingDiscover(ctx, s, libraryID, libraryIDs, filter)
case SectionAdminCuratedList:
return f.fetchAdminCuratedList(ctx, s, libraryID, libraryIDs, filter)
default:
@@ -2334,6 +2345,227 @@ func scanMediaItems(rows pgx.Rows) ([]*models.MediaItem, error) {
return items, rows.Err()
}
// trendingDiscoverEntry is a provider-agnostic external trending result.
type trendingDiscoverEntry struct {
tmdbID string
imdbID string
tvdbID string
mediaType string // "movie" | "tv"
}
// fetchTrendingDiscover surfaces external global trending (TMDB or Trakt),
// mixing movies + series, matched to titles in the viewer's enabled libraries.
// The external fetch + ID resolution are cached briefly so it does not hit the
// upstream API on every home-page load.
func (f *Fetcher) fetchTrendingDiscover(ctx context.Context, s ResolvedSection, libraryID *int, libraryIDs []int, filter catalog.AccessFilter) ([]*models.MediaItem, int, error) {
var p recipes.TrendingDiscoverParams
if len(s.Config) > 0 {
_ = json.Unmarshal(s.Config, &p)
}
source := p.Source
if source != "trakt" {
source = "tmdb"
}
window := p.Window
if window != "day" {
window = "week"
}
limit := s.ItemLimit
if limit <= 0 {
limit = 20
}
// Over-fetch: library-only matching drops globally-trending titles the
// server does not own, so request more candidates than the display limit.
fetchLimit := limit * 5
if fetchLimit < 50 {
fetchLimit = 50
}
if fetchLimit > 200 {
fetchLimit = 200
}
orderedIDs, err := f.loadTrendingDiscoverContentIDs(ctx, source, window, fetchLimit)
if err != nil {
return nil, 0, err
}
if len(orderedIDs) == 0 {
return []*models.MediaItem{}, 0, nil
}
items, err := f.fetchItemsByContentIDs(ctx, orderedIDs, libraryID, libraryIDs, filter)
if err != nil {
return nil, 0, err
}
// Re-order to trending rank (fetchItemsByContentIDs returns DB order) and
// truncate to the section's display limit.
byID := make(map[string]*models.MediaItem, len(items))
for _, item := range items {
byID[item.ContentID] = item
}
ordered := make([]*models.MediaItem, 0, len(orderedIDs))
for _, id := range orderedIDs {
item, ok := byID[id]
if !ok {
continue
}
ordered = append(ordered, item)
if len(ordered) >= limit {
break
}
}
return ordered, len(ordered), nil
}
// loadTrendingDiscoverContentIDs fetches the external trending list and resolves
// it to ordered library content IDs, cached for an hour per (source, window).
func (f *Fetcher) loadTrendingDiscoverContentIDs(ctx context.Context, source, window string, fetchLimit int) ([]string, error) {
cache := f.ensureEditorialCandidateCache()
cacheKey := fmt.Sprintf("trending_discover|%s|%s|%d", source, window, fetchLimit)
now := f.Clock.Now()
if cached, ok := cache.get(cacheKey, now); ok {
return cached, nil
}
entries, err := f.fetchTrendingDiscoverEntries(ctx, source, window, fetchLimit)
if err != nil {
return nil, err
}
// Don't cache when the provider is unconfigured/empty, so a newly-configured
// provider takes effect immediately rather than after the TTL.
if len(entries) == 0 {
return []string{}, nil
}
contentIDs, err := f.resolveTrendingDiscoverIDs(ctx, entries)
if err != nil {
return nil, err
}
cache.set(cacheKey, contentIDs, now.Add(time.Hour))
return contentIDs, nil
}
// fetchTrendingDiscoverEntries pulls the raw trending list from the configured
// provider. A nil/unconfigured provider yields an empty list (no error) so the
// section simply renders empty.
func (f *Fetcher) fetchTrendingDiscoverEntries(ctx context.Context, source, window string, fetchLimit int) ([]trendingDiscoverEntry, error) {
if source == "trakt" {
if f.TraktTrending == nil {
return nil, nil
}
// Trakt has no mixed endpoint; fetch movies + shows and concatenate.
movies, movieErr := f.TraktTrending.GetCollectionPreset(ctx, "trending", "movie", fetchLimit, "")
shows, showErr := f.TraktTrending.GetCollectionPreset(ctx, "trending", "tv", fetchLimit, "")
if movieErr != nil && showErr != nil {
return nil, fmt.Errorf("trakt trending: %v / %v", movieErr, showErr)
}
merged := append(append([]catalog.TraktCollectionEntry{}, movies...), shows...)
out := make([]trendingDiscoverEntry, 0, len(merged))
for _, e := range merged {
entry := trendingDiscoverEntry{imdbID: e.IMDbID, mediaType: e.MediaType}
if e.TMDBID > 0 {
entry.tmdbID = strconv.Itoa(e.TMDBID)
}
if e.TVDBID > 0 {
entry.tvdbID = strconv.Itoa(e.TVDBID)
}
out = append(out, entry)
}
return out, nil
}
// Default: TMDB /trending/all/{window} — natively mixed movies + series.
if f.TMDBTrending == nil {
return nil, nil
}
entries, err := f.TMDBTrending.GetCollectionPreset(ctx, "trending", "all", window, fetchLimit)
if err != nil {
return nil, err
}
out := make([]trendingDiscoverEntry, 0, len(entries))
for _, e := range entries {
entry := trendingDiscoverEntry{imdbID: e.IMDbID, mediaType: e.MediaType}
if e.ID > 0 {
entry.tmdbID = strconv.Itoa(e.ID)
}
if e.TVDBID > 0 {
entry.tvdbID = strconv.Itoa(e.TVDBID)
}
out = append(out, entry)
}
return out, nil
}
// resolveTrendingDiscoverIDs matches trending entries to library content IDs via
// two batched external-ID lookups (movies, series), preserving trending order.
func (f *Fetcher) resolveTrendingDiscoverIDs(ctx context.Context, entries []trendingDiscoverEntry) ([]string, error) {
if f.ItemRepo == nil {
return nil, fmt.Errorf("trending_discover: item repository not configured")
}
var movieBatch, seriesBatch catalog.ExternalIDBatch
for _, e := range entries {
batch := &movieBatch
if e.mediaType == "tv" {
batch = &seriesBatch
}
if e.tmdbID != "" {
batch.TMDBIDs = append(batch.TMDBIDs, e.tmdbID)
}
if e.imdbID != "" {
batch.IMDbIDs = append(batch.IMDbIDs, e.imdbID)
}
if e.tvdbID != "" {
batch.TVDBIDs = append(batch.TVDBIDs, e.tvdbID)
}
}
movieLookup, err := f.ItemRepo.GetByExternalIDs(ctx, movieBatch, "movie")
if err != nil {
return nil, err
}
seriesLookup, err := f.ItemRepo.GetByExternalIDs(ctx, seriesBatch, "series")
if err != nil {
return nil, err
}
return orderedTrendingContentIDs(entries, movieLookup, seriesLookup), nil
}
// orderedTrendingContentIDs maps trending entries to library content IDs in
// trending order, preferring TVDB (series) > TMDB > IMDb, de-duplicated.
func orderedTrendingContentIDs(entries []trendingDiscoverEntry, movieLookup, seriesLookup *catalog.ExternalIDLookup) []string {
seen := make(map[string]struct{}, len(entries))
out := make([]string, 0, len(entries))
for _, e := range entries {
lookup := movieLookup
isSeries := e.mediaType == "tv"
if isSeries {
lookup = seriesLookup
}
if lookup == nil {
continue
}
var id string
if isSeries && e.tvdbID != "" {
id = lookup.ByTVDB[e.tvdbID]
}
if id == "" && e.tmdbID != "" {
id = lookup.ByTMDB[e.tmdbID]
}
if id == "" && e.imdbID != "" {
id = lookup.ByIMDb[e.imdbID]
}
if id == "" {
continue
}
if _, dup := seen[id]; dup {
continue
}
seen[id] = struct{}{}
out = append(out, id)
}
return out
}
func (f *Fetcher) fetchTrending(ctx context.Context, s ResolvedSection, libraryID *int, libraryIDs []int, filter catalog.AccessFilter) ([]*models.MediaItem, int, error) {
var p recipes.TrendingParams
if len(s.Config) > 0 {
@@ -0,0 +1,61 @@
package recipes
import (
"encoding/json"
"errors"
"time"
)
// TrendingDiscoverParams configures the trending_discover section: external
// global trending pulled from a single source (TMDB or Trakt), mixing movies +
// series in one list, matched to titles already in the library.
type TrendingDiscoverParams struct {
Source string `json:"source"` // "tmdb" | "trakt"
Window string `json:"window"` // "day" | "week" (TMDB only; ignored by Trakt)
}
type trendingDiscoverRecipe struct{}
func (trendingDiscoverRecipe) Type() string { return "trending_discover" }
func (trendingDiscoverRecipe) NewParams() any { return &TrendingDiscoverParams{} }
func (trendingDiscoverRecipe) DefaultCacheTTL() time.Duration { return time.Hour }
func (trendingDiscoverRecipe) Resolve(rc ResolverContext) (ResolvedItems, error) {
return delegateResolve("trending_discover", rc)
}
func (trendingDiscoverRecipe) Validate(raw json.RawMessage) error {
if len(raw) == 0 {
return nil
}
var p TrendingDiscoverParams
if err := json.Unmarshal(raw, &p); err != nil {
return err
}
switch p.Source {
case "", "tmdb", "trakt":
default:
return errors.New(`trending_discover: source must be "tmdb" or "trakt"`)
}
switch p.Window {
case "", "day", "week":
default:
return errors.New(`trending_discover: window must be "day" or "week"`)
}
return nil
}
func (trendingDiscoverRecipe) Definition() RecipeDefinition {
return RecipeDefinition{
Type: "trending_discover",
Category: CategorySocial,
Presets: []GalleryPreset{
{Key: "tdisc_tmdb_day", DisplayName: "TMDB Trending Today", Icon: "🔥", DescriptionShort: "Today's trending movies & shows from TMDB, matched to your library.", DefaultParams: json.RawMessage(`{"source":"tmdb","window":"day"}`)},
{Key: "tdisc_tmdb_week", DisplayName: "TMDB Trending This Week", Icon: "🔥", DescriptionShort: "This week's trending movies & shows from TMDB, matched to your library.", DefaultParams: json.RawMessage(`{"source":"tmdb","window":"week"}`)},
{Key: "tdisc_trakt", DisplayName: "Trakt Trending", Icon: "📈", DescriptionShort: "Trending movies & shows on Trakt, matched to your library.", DefaultParams: json.RawMessage(`{"source":"trakt","window":"week"}`)},
},
}
}
func init() {
Register(trendingDiscoverRecipe{})
}
@@ -0,0 +1,67 @@
package sections
import (
"testing"
"github.com/Silo-Server/silo-server/internal/catalog"
)
func TestOrderedTrendingContentIDs_PreservesOrderAndSeriesPrefersTVDB(t *testing.T) {
entries := []trendingDiscoverEntry{
{tmdbID: "1", mediaType: "movie"},
{tmdbID: "2", tvdbID: "20", mediaType: "tv"},
{imdbID: "tt3", mediaType: "movie"},
}
movieLookup := &catalog.ExternalIDLookup{
ByTMDB: map[string]string{"1": "cm1"},
ByIMDb: map[string]string{"tt3": "cm3"},
ByTVDB: map[string]string{},
}
seriesLookup := &catalog.ExternalIDLookup{
ByTVDB: map[string]string{"20": "cs2"},
ByTMDB: map[string]string{"2": "cs2_tmdb"}, // TVDB should win for series
ByIMDb: map[string]string{},
}
got := orderedTrendingContentIDs(entries, movieLookup, seriesLookup)
want := []string{"cm1", "cs2", "cm3"}
if len(got) != len(want) {
t.Fatalf("got %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("pos %d = %q want %q (full %v)", i, got[i], want[i], got)
}
}
}
func TestOrderedTrendingContentIDs_SkipsUnmatchedAndDedups(t *testing.T) {
entries := []trendingDiscoverEntry{
{tmdbID: "1", mediaType: "movie"}, // matches cX
{tmdbID: "404", mediaType: "movie"}, // no match -> skipped
{imdbID: "ttX", mediaType: "movie"}, // also resolves to cX -> deduped
}
movieLookup := &catalog.ExternalIDLookup{
ByTMDB: map[string]string{"1": "cX"},
ByIMDb: map[string]string{"ttX": "cX"},
ByTVDB: map[string]string{},
}
got := orderedTrendingContentIDs(entries, movieLookup, &catalog.ExternalIDLookup{})
if len(got) != 1 || got[0] != "cX" {
t.Fatalf("expected [cX], got %v", got)
}
}
func TestOrderedTrendingContentIDs_MovieIgnoresTVDB(t *testing.T) {
entries := []trendingDiscoverEntry{
{tvdbID: "50", tmdbID: "5", mediaType: "movie"},
}
movieLookup := &catalog.ExternalIDLookup{
ByTVDB: map[string]string{"50": "cTV"}, // must be ignored for movies
ByTMDB: map[string]string{"5": "cTMDB"},
ByIMDb: map[string]string{},
}
got := orderedTrendingContentIDs(entries, movieLookup, &catalog.ExternalIDLookup{})
if len(got) != 1 || got[0] != "cTMDB" {
t.Fatalf("movie should match TMDB not TVDB; got %v", got)
}
}
+3
View File
@@ -40,6 +40,8 @@ const (
SectionNewToLibrary SectionType = "new_to_library"
SectionMostWatched SectionType = "most_watched"
SectionTrendingDiscover SectionType = "trending_discover"
SectionAdminCuratedList SectionType = "admin_curated_list"
)
@@ -71,6 +73,7 @@ var ValidSectionTypes = map[SectionType]bool{
SectionProfileActivityFeed: true,
SectionNewToLibrary: true,
SectionMostWatched: true,
SectionTrendingDiscover: true,
SectionAdminCuratedList: true,
}