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:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user