From ba4fd9d9fd5c4318253b5061ebfcd48dc1fab4ec Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Fri, 29 May 2026 09:25:17 -0400 Subject: [PATCH] feat(sections): add trending_discover home section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- internal/api/router.go | 6 + internal/sections/fetcher.go | 238 +++++++++++++++++- .../sections/recipes/trending_discover.go | 61 +++++ internal/sections/trending_discover_test.go | 67 +++++ internal/sections/types.go | 3 + 5 files changed, 372 insertions(+), 3 deletions(-) create mode 100644 internal/sections/recipes/trending_discover.go create mode 100644 internal/sections/trending_discover_test.go diff --git a/internal/api/router.go b/internal/api/router.go index 75e38dfa..9125eedf 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -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, diff --git a/internal/sections/fetcher.go b/internal/sections/fetcher.go index 9a771d7e..df0168e1 100644 --- a/internal/sections/fetcher.go +++ b/internal/sections/fetcher.go @@ -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 { diff --git a/internal/sections/recipes/trending_discover.go b/internal/sections/recipes/trending_discover.go new file mode 100644 index 00000000..c5fba9cb --- /dev/null +++ b/internal/sections/recipes/trending_discover.go @@ -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{}) +} diff --git a/internal/sections/trending_discover_test.go b/internal/sections/trending_discover_test.go new file mode 100644 index 00000000..778a922a --- /dev/null +++ b/internal/sections/trending_discover_test.go @@ -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) + } +} diff --git a/internal/sections/types.go b/internal/sections/types.go index 10c8d8ec..6506f9b3 100644 --- a/internal/sections/types.go +++ b/internal/sections/types.go @@ -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, }