* feat(collections): back collections with user-authored Trakt lists Collections could sync only Trakt's built-in trending/popular/recommended feeds; a server admin could not populate a collection from a specific user's Trakt list (e.g. a curated 'Saw in timeline order' list) (#214). - trakt.Client.GetUserList fetches /users/{user}/lists/{slug}/items in list order, mixing movies and shows and skipping non-title entries. - New 'trakt_list' collection source mode: catalog.ParseTraktListURL accepts a trakt.tv list URL (or bare user/slug), and syncTraktListCollection reuses the preset pipeline's matching/ordering via an extracted completeTraktEntrySync helper. Public lists need no access token. - Trakt import handler accepts list_url as an alternative to preset; the admin collection editor's Trakt form gains a Source toggle (discovery feed vs user list) with a list-URL input. Additive-only: new source mode + optional request field; preset path unchanged. Fixes #214 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(collections): round-trip trakt_list through the edit form, unlock mixed libraries, validate list host Three review fixes for user-list-backed collections: - The admin edit form now detects mode "trakt_list", shows an editable list URL (mirroring the create form) and saves the source back as trakt_list with list_url preserved — previously any edit silently rewrote the collection into a trakt_preset Trending Movies feed. - Library eligibility in list mode is mixed (movies + shows) instead of inheriting the hidden media-type default of movie, since Trakt lists mix both and entries match by their own type. - ParseTraktListURL only accepts trakt.tv / www.trakt.tv hosts, so a list-shaped URL on another domain fails fast with the format error instead of a confusing later sync failure. source_config now carries list_url alongside the legacy url key (additive); sync reads list_url, then url, then source_url. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
298 lines
9.9 KiB
Go
298 lines
9.9 KiB
Go
package sections
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/catalog"
|
|
"github.com/Silo-Server/silo-server/internal/sections/recipes"
|
|
)
|
|
|
|
type fakeSectionLister struct {
|
|
configs []json.RawMessage
|
|
err error
|
|
}
|
|
|
|
func (f fakeSectionLister) ListTrendingDiscoverConfigs(context.Context) ([]json.RawMessage, error) {
|
|
return f.configs, f.err
|
|
}
|
|
|
|
type savedSnap struct {
|
|
contentIDs []string
|
|
entryCount int
|
|
status string
|
|
}
|
|
|
|
type attemptRec struct {
|
|
status string
|
|
message string
|
|
}
|
|
|
|
type fakeSnapshotStore struct {
|
|
saved map[string]savedSnap
|
|
attempts map[string]attemptRec
|
|
}
|
|
|
|
func newFakeSnapshotStore() *fakeSnapshotStore {
|
|
return &fakeSnapshotStore{saved: map[string]savedSnap{}, attempts: map[string]attemptRec{}}
|
|
}
|
|
|
|
func (f *fakeSnapshotStore) SaveSuccess(_ context.Context, source, window string, contentIDs []string, entryCount int, status string, _ time.Time) error {
|
|
f.saved[source+"|"+window] = savedSnap{contentIDs: contentIDs, entryCount: entryCount, status: status}
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeSnapshotStore) RecordAttempt(_ context.Context, source, window, status, message string, _ time.Time) error {
|
|
f.attempts[source+"|"+window] = attemptRec{status: status, message: message}
|
|
return nil
|
|
}
|
|
|
|
type fakeTMDB struct {
|
|
entries []catalog.TMDBCollectionEntry
|
|
err error
|
|
}
|
|
|
|
func (f fakeTMDB) GetCollectionPreset(context.Context, string, string, string, int) ([]catalog.TMDBCollectionEntry, error) {
|
|
return f.entries, f.err
|
|
}
|
|
|
|
type fakeTrakt struct {
|
|
byMediaType map[string][]catalog.TraktCollectionEntry
|
|
errByType map[string]error
|
|
}
|
|
|
|
func (f fakeTrakt) GetUserList(context.Context, string, string, int, string) ([]catalog.TraktCollectionEntry, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (f fakeTrakt) GetCollectionPreset(_ context.Context, _, mediaType string, _ int, _ string) ([]catalog.TraktCollectionEntry, error) {
|
|
if err := f.errByType[mediaType]; err != nil {
|
|
return nil, err
|
|
}
|
|
return f.byMediaType[mediaType], nil
|
|
}
|
|
|
|
type fakeResolver struct {
|
|
byType map[string]*catalog.ExternalIDLookup
|
|
}
|
|
|
|
func (f fakeResolver) GetByExternalIDs(_ context.Context, _ catalog.ExternalIDBatch, itemType string) (*catalog.ExternalIDLookup, error) {
|
|
if lk, ok := f.byType[itemType]; ok {
|
|
return lk, nil
|
|
}
|
|
return &catalog.ExternalIDLookup{ByTMDB: map[string]string{}, ByIMDb: map[string]string{}, ByTVDB: map[string]string{}}, nil
|
|
}
|
|
|
|
func tmdbConfig(t *testing.T, source, window string) json.RawMessage {
|
|
t.Helper()
|
|
raw, err := json.Marshal(recipes.TrendingDiscoverParams{Source: source, Window: window})
|
|
if err != nil {
|
|
t.Fatalf("marshal config: %v", err)
|
|
}
|
|
return raw
|
|
}
|
|
|
|
func TestRefresherSavesOrderedContentIDs(t *testing.T) {
|
|
store := newFakeSnapshotStore()
|
|
r := &TrendingRefresher{
|
|
Sections: fakeSectionLister{configs: []json.RawMessage{tmdbConfig(t, "tmdb", "week")}},
|
|
Snapshots: store,
|
|
Resolver: fakeResolver{byType: map[string]*catalog.ExternalIDLookup{
|
|
"movie": {ByTMDB: map[string]string{"10": "c-movie"}, ByIMDb: map[string]string{}, ByTVDB: map[string]string{}},
|
|
"series": {ByTMDB: map[string]string{"20": "c-series"}, ByIMDb: map[string]string{}, ByTVDB: map[string]string{}},
|
|
}},
|
|
TMDBTrending: fakeTMDB{entries: []catalog.TMDBCollectionEntry{
|
|
{ID: 10, MediaType: "movie"},
|
|
{ID: 20, MediaType: "tv"},
|
|
}},
|
|
Clock: recipes.FixedClock(time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC)),
|
|
}
|
|
|
|
data, err := r.RunOnce(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("RunOnce: %v", err)
|
|
}
|
|
|
|
var result TrendingRefreshResult
|
|
if err := json.Unmarshal(data, &result); err != nil {
|
|
t.Fatalf("unmarshal result: %v", err)
|
|
}
|
|
if result.Combos != 1 || result.Refreshed != 1 || result.Failed != 0 || result.Empty != 0 {
|
|
t.Fatalf("result = %+v; want {Combos:1 Refreshed:1 Empty:0 Failed:0}", result)
|
|
}
|
|
|
|
got := store.saved["tmdb|week"]
|
|
want := []string{"c-movie", "c-series"}
|
|
if len(got.contentIDs) != len(want) || got.contentIDs[0] != want[0] || got.contentIDs[1] != want[1] {
|
|
t.Fatalf("saved content IDs = %v; want %v", got.contentIDs, want)
|
|
}
|
|
if got.status != "ok" || got.entryCount != 2 {
|
|
t.Fatalf("saved snap = %+v; want status ok, entryCount 2", got)
|
|
}
|
|
}
|
|
|
|
func TestRefresherFailurePreservesLastGood(t *testing.T) {
|
|
store := newFakeSnapshotStore()
|
|
r := &TrendingRefresher{
|
|
Sections: fakeSectionLister{configs: []json.RawMessage{tmdbConfig(t, "tmdb", "week")}},
|
|
Snapshots: store,
|
|
Resolver: fakeResolver{},
|
|
TMDBTrending: fakeTMDB{err: errors.New("tmdb 503")},
|
|
Clock: recipes.FixedClock(time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC)),
|
|
}
|
|
|
|
data, err := r.RunOnce(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("RunOnce: %v", err)
|
|
}
|
|
|
|
if _, ok := store.saved["tmdb|week"]; ok {
|
|
t.Fatal("SaveSuccess must not be called on fetch failure (would clear last-good)")
|
|
}
|
|
att, ok := store.attempts["tmdb|week"]
|
|
if !ok || att.status != "error" {
|
|
t.Fatalf("attempt = %+v, ok=%v; want status error", att, ok)
|
|
}
|
|
|
|
var result TrendingRefreshResult
|
|
_ = json.Unmarshal(data, &result)
|
|
if result.Failed != 1 {
|
|
t.Fatalf("result.Failed = %d; want 1", result.Failed)
|
|
}
|
|
}
|
|
|
|
func TestRefresherEmptyProviderPreservesLastGood(t *testing.T) {
|
|
store := newFakeSnapshotStore()
|
|
r := &TrendingRefresher{
|
|
Sections: fakeSectionLister{configs: []json.RawMessage{tmdbConfig(t, "tmdb", "week")}},
|
|
Snapshots: store,
|
|
Resolver: fakeResolver{},
|
|
// TMDBTrending nil => provider unconfigured => empty entries, no error.
|
|
Clock: recipes.FixedClock(time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC)),
|
|
}
|
|
|
|
data, err := r.RunOnce(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("RunOnce: %v", err)
|
|
}
|
|
if _, ok := store.saved["tmdb|week"]; ok {
|
|
t.Fatal("SaveSuccess must not be called when provider returns no entries")
|
|
}
|
|
att := store.attempts["tmdb|week"]
|
|
if att.status != "empty" {
|
|
t.Fatalf("attempt status = %q; want empty", att.status)
|
|
}
|
|
var result TrendingRefreshResult
|
|
_ = json.Unmarshal(data, &result)
|
|
if result.Empty != 1 {
|
|
t.Fatalf("result.Empty = %d; want 1", result.Empty)
|
|
}
|
|
}
|
|
|
|
func TestRefresherSkipsPersonEntries(t *testing.T) {
|
|
store := newFakeSnapshotStore()
|
|
r := &TrendingRefresher{
|
|
Sections: fakeSectionLister{configs: []json.RawMessage{tmdbConfig(t, "tmdb", "week")}},
|
|
Snapshots: store,
|
|
Resolver: fakeResolver{byType: map[string]*catalog.ExternalIDLookup{
|
|
// "99" is present in the movie lookup to simulate a person ID that
|
|
// collides with an unrelated library movie's TMDB ID.
|
|
"movie": {ByTMDB: map[string]string{"10": "c-movie", "99": "c-person-collision"}, ByIMDb: map[string]string{}, ByTVDB: map[string]string{}},
|
|
"series": {ByTMDB: map[string]string{"20": "c-series"}, ByIMDb: map[string]string{}, ByTVDB: map[string]string{}},
|
|
}},
|
|
TMDBTrending: fakeTMDB{entries: []catalog.TMDBCollectionEntry{
|
|
{ID: 10, MediaType: "movie"},
|
|
{ID: 99, MediaType: "person"},
|
|
{ID: 20, MediaType: "tv"},
|
|
}},
|
|
Clock: recipes.FixedClock(time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC)),
|
|
}
|
|
|
|
if _, err := r.RunOnce(context.Background()); err != nil {
|
|
t.Fatalf("RunOnce: %v", err)
|
|
}
|
|
|
|
got := store.saved["tmdb|week"].contentIDs
|
|
want := []string{"c-movie", "c-series"}
|
|
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
|
|
t.Fatalf("content IDs = %v; want %v (person entry must be skipped)", got, want)
|
|
}
|
|
}
|
|
|
|
func TestRefresherTraktInterleavesMoviesAndShows(t *testing.T) {
|
|
store := newFakeSnapshotStore()
|
|
r := &TrendingRefresher{
|
|
Sections: fakeSectionLister{configs: []json.RawMessage{tmdbConfig(t, "trakt", "week")}},
|
|
Snapshots: store,
|
|
Resolver: fakeResolver{byType: map[string]*catalog.ExternalIDLookup{
|
|
"movie": {ByTMDB: map[string]string{"1": "m1", "2": "m2"}, ByIMDb: map[string]string{}, ByTVDB: map[string]string{}},
|
|
"series": {ByTMDB: map[string]string{"3": "s1"}, ByIMDb: map[string]string{}, ByTVDB: map[string]string{}},
|
|
}},
|
|
TraktTrending: fakeTrakt{byMediaType: map[string][]catalog.TraktCollectionEntry{
|
|
"movie": {{TMDBID: 1, MediaType: "movie"}, {TMDBID: 2, MediaType: "movie"}},
|
|
"tv": {{TMDBID: 3, MediaType: "tv"}},
|
|
}},
|
|
Clock: recipes.FixedClock(time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC)),
|
|
}
|
|
|
|
if _, err := r.RunOnce(context.Background()); err != nil {
|
|
t.Fatalf("RunOnce: %v", err)
|
|
}
|
|
|
|
// Interleaved order: movie[0], show[0], movie[1] => m1, s1, m2. A plain
|
|
// concat would have buried s1 after all movies.
|
|
got := store.saved["trakt|week"].contentIDs
|
|
want := []string{"m1", "s1", "m2"}
|
|
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] || got[2] != want[2] {
|
|
t.Fatalf("content IDs = %v; want %v (interleaved)", got, want)
|
|
}
|
|
}
|
|
|
|
func TestRefresherTraktPartialFailurePreservesLastGood(t *testing.T) {
|
|
store := newFakeSnapshotStore()
|
|
r := &TrendingRefresher{
|
|
Sections: fakeSectionLister{configs: []json.RawMessage{tmdbConfig(t, "trakt", "week")}},
|
|
Snapshots: store,
|
|
Resolver: fakeResolver{},
|
|
TraktTrending: fakeTrakt{
|
|
byMediaType: map[string][]catalog.TraktCollectionEntry{"movie": {{TMDBID: 1, MediaType: "movie"}}},
|
|
errByType: map[string]error{"tv": errors.New("trakt shows 500")},
|
|
},
|
|
Clock: recipes.FixedClock(time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC)),
|
|
}
|
|
|
|
if _, err := r.RunOnce(context.Background()); err != nil {
|
|
t.Fatalf("RunOnce: %v", err)
|
|
}
|
|
|
|
if _, ok := store.saved["trakt|week"]; ok {
|
|
t.Fatal("SaveSuccess must not run when one Trakt sub-fetch fails (would drop a media type)")
|
|
}
|
|
if store.attempts["trakt|week"].status != "error" {
|
|
t.Fatalf("attempt status = %q; want error", store.attempts["trakt|week"].status)
|
|
}
|
|
}
|
|
|
|
func TestDistinctTrendingCombosCollapsesTrakt(t *testing.T) {
|
|
configs := []json.RawMessage{
|
|
tmdbConfig(t, "trakt", "day"),
|
|
tmdbConfig(t, "trakt", "week"),
|
|
tmdbConfig(t, "tmdb", "day"),
|
|
tmdbConfig(t, "tmdb", "day"),
|
|
}
|
|
got := distinctTrendingCombos(configs)
|
|
if len(got) != 2 {
|
|
t.Fatalf("distinctTrendingCombos len = %d (%+v); want 2", len(got), got)
|
|
}
|
|
seen := map[trendingCombo]bool{}
|
|
for _, c := range got {
|
|
seen[c] = true
|
|
}
|
|
if !seen[trendingCombo{"trakt", "week"}] || !seen[trendingCombo{"tmdb", "day"}] {
|
|
t.Fatalf("combos = %+v; want {trakt week} and {tmdb day}", got)
|
|
}
|
|
}
|