diff --git a/internal/catalog/catalog_resolver.go b/internal/catalog/catalog_resolver.go index 15b317f2..61fce497 100644 --- a/internal/catalog/catalog_resolver.go +++ b/internal/catalog/catalog_resolver.go @@ -1696,6 +1696,11 @@ func (r *CatalogResolver) loadPersonalSourceIDs(ctx context.Context, store users if err != nil { return nil, err } + // The episode scope shows history at episode granularity; every other + // scope collapses episode watch events into their series display item. + if isEpisodeCatalogScope(req.Query.MediaScope) { + return HistoryEpisodeScopeIDs(entries), nil + } return ResolveHistoryDisplayIDs(ctx, entries, NewEpisodeRepository(r.itemRepo.pool)) default: return nil, fmt.Errorf("%w: source %q is not a personal source", ErrInvalidCatalogRequest, req.Source) @@ -1806,6 +1811,10 @@ func (r *CatalogResolver) fetchAccessibleItemsByID(ctx context.Context, contentI return []*models.MediaItem{}, nil } + if isEpisodeCatalogScope(req.Query.MediaScope) { + return r.fetchAccessibleEpisodeItemsByID(ctx, contentIDs, req, access) + } + filters, earlyEmpty, err := catalogBrowseFilters(req, access) if err != nil { return nil, err @@ -1835,6 +1844,61 @@ func (r *CatalogResolver) fetchAccessibleItemsByID(ctx context.Context, contentI return ordered, nil } +// fetchAccessibleEpisodeItemsByID is the episode-scope counterpart of +// fetchAccessibleItemsByID. Episode rows are not present in media_items — they +// hydrate through the episode catalog relation — so the browse-repository path +// (which scans media_items and would match nothing) is replaced by the episode +// query executor with the requested ids as the allowed-content set. Query +// filters are applied in the same pass; sort and limit are stripped because +// the caller re-imposes source order or its own sort downstream. +func (r *CatalogResolver) fetchAccessibleEpisodeItemsByID(ctx context.Context, contentIDs []string, req CatalogRequest, access AccessFilter) ([]*models.MediaItem, error) { + filterDef := req.Query + filterDef.Sort = QuerySort{} + filterDef.Limit = nil + + queryAccess := access + if access.AllowedContentIDs != nil { + queryAccess.AllowedContentIDs = intersectContentIDs(contentIDs, access.AllowedContentIDs) + } else { + queryAccess.AllowedContentIDs = contentIDs + } + + executor := r.queryExecutorForScope(filterDef.MediaScope, nil) + items, _, err := executor.Preview(ctx, filterDef, queryAccess, len(contentIDs)) + if err != nil { + return nil, err + } + + byID := make(map[string]*models.MediaItem, len(items)) + for _, item := range items { + if item != nil { + byID[item.ContentID] = item + } + } + + ordered := make([]*models.MediaItem, 0, len(contentIDs)) + for _, contentID := range contentIDs { + if item, ok := byID[contentID]; ok { + ordered = append(ordered, item) + } + } + return ordered, nil +} + +func intersectContentIDs(ids []string, allowed []string) []string { + allowedSet := make(map[string]struct{}, len(allowed)) + for _, id := range allowed { + allowedSet[id] = struct{}{} + } + intersection := make([]string, 0, len(ids)) + for _, id := range ids { + if _, ok := allowedSet[id]; ok { + intersection = append(intersection, id) + } + } + return intersection +} + func (r *CatalogResolver) fetchAllBrowseCandidates(ctx context.Context, req CatalogRequest, access AccessFilter) ([]*models.MediaItem, error) { filters, earlyEmpty, err := catalogBrowseFilters(req, access) if err != nil { diff --git a/internal/catalog/history_display.go b/internal/catalog/history_display.go index 8c66d6e4..67268aeb 100644 --- a/internal/catalog/history_display.go +++ b/internal/catalog/history_display.go @@ -7,6 +7,29 @@ import ( "github.com/Silo-Server/silo-server/internal/userstore" ) +// HistoryEpisodeScopeIDs returns history entry ids for the episode-scoped +// history view: each watched item keeps its own id (episodes are NOT collapsed +// into their series), deduplicated to the most recent watch — entries arrive +// most-recent-first from ListHistory. Non-episode ids (movies, audiobooks) +// pass through unchanged; the episode catalog relation drops them at +// hydration, so no episodes lookup is needed here. +func HistoryEpisodeScopeIDs(entries []userstore.WatchHistoryEntry) []string { + ids := make([]string, 0, len(entries)) + seen := make(map[string]struct{}, len(entries)) + for _, entry := range entries { + mediaItemID := strings.TrimSpace(entry.MediaItemID) + if mediaItemID == "" { + continue + } + if _, ok := seen[mediaItemID]; ok { + continue + } + seen[mediaItemID] = struct{}{} + ids = append(ids, mediaItemID) + } + return ids +} + func ResolveHistoryDisplayIDs(ctx context.Context, entries []userstore.WatchHistoryEntry, episodeRepo *EpisodeRepository) ([]string, error) { episodeSeriesByID := make(map[string]string) if episodeRepo != nil { diff --git a/internal/catalog/history_display_test.go b/internal/catalog/history_display_test.go new file mode 100644 index 00000000..f0879e4e --- /dev/null +++ b/internal/catalog/history_display_test.go @@ -0,0 +1,30 @@ +package catalog + +import ( + "reflect" + "testing" + + "github.com/Silo-Server/silo-server/internal/userstore" +) + +func TestHistoryEpisodeScopeIDs(t *testing.T) { + entries := []userstore.WatchHistoryEntry{ + {MediaItemID: "episode-tmdb-100-1-3"}, + {MediaItemID: "movie-tmdb-555"}, + {MediaItemID: "episode-tmdb-100-1-3"}, // older rewatch collapses into the first + {MediaItemID: " "}, + {MediaItemID: "episode-tmdb-100-1-2"}, + } + + got := HistoryEpisodeScopeIDs(entries) + want := []string{"episode-tmdb-100-1-3", "movie-tmdb-555", "episode-tmdb-100-1-2"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("HistoryEpisodeScopeIDs = %v, want %v", got, want) + } +} + +func TestHistoryEpisodeScopeIDsEmpty(t *testing.T) { + if got := HistoryEpisodeScopeIDs(nil); len(got) != 0 { + t.Fatalf("HistoryEpisodeScopeIDs(nil) = %v, want empty", got) + } +} diff --git a/internal/catalog/history_episode_scope_db_test.go b/internal/catalog/history_episode_scope_db_test.go new file mode 100644 index 00000000..36b4de59 --- /dev/null +++ b/internal/catalog/history_episode_scope_db_test.go @@ -0,0 +1,146 @@ +package catalog + +import ( + "context" + "fmt" + "os" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/Silo-Server/silo-server/internal/userstore" + "github.com/Silo-Server/silo-server/internal/userstore/pgstore" +) + +// TestResolveHistoryEpisodeScope verifies the two history granularities end to +// end: the default (unscoped) history view collapses episode watch events into +// one series display item, while the episode media scope resolves the same +// history rows to individual episode items in most-recent-first watch order. +func TestResolveHistoryEpisodeScope(t *testing.T) { + dsn := os.Getenv("SILO_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("SILO_TEST_DATABASE_URL is not set") + } + ctx := context.Background() + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Fatalf("connect test database: %v", err) + } + t.Cleanup(pool.Close) + + suffix := time.Now().UnixNano() + seriesID := fmt.Sprintf("hes-series-%d", suffix) + ep1 := fmt.Sprintf("hes-ep1-%d", suffix) + ep2 := fmt.Sprintf("hes-ep2-%d", suffix) + + var folderID int + if err := pool.QueryRow(ctx, + `INSERT INTO media_folders (type, name, enabled) VALUES ('series', 'HES Test', true) RETURNING id`, + ).Scan(&folderID); err != nil { + t.Fatalf("seed folder: %v", err) + } + var userID int + if err := pool.QueryRow(ctx, + `INSERT INTO users (username, role) VALUES ($1, 'user') RETURNING id`, + fmt.Sprintf("hes-user-%d", suffix), + ).Scan(&userID); err != nil { + t.Fatalf("seed user: %v", err) + } + profileID := fmt.Sprintf("00000000-0000-4000-8000-%012d", suffix%1_000_000_000_000) + if _, err := pool.Exec(ctx, + `INSERT INTO user_profiles (id, user_id, name) VALUES ($1, $2, 'HES Profile')`, + profileID, userID, + ); err != nil { + t.Fatalf("seed profile: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(ctx, `DELETE FROM user_watch_history WHERE user_id = $1`, userID) + _, _ = pool.Exec(ctx, `DELETE FROM user_profiles WHERE user_id = $1`, userID) + _, _ = pool.Exec(ctx, `DELETE FROM users WHERE id = $1`, userID) + // episodes and episode_libraries cascade from the series row. + _, _ = pool.Exec(ctx, `DELETE FROM media_items WHERE content_id = $1`, seriesID) + _, _ = pool.Exec(ctx, `DELETE FROM media_folders WHERE id = $1`, folderID) + }) + + if _, err := pool.Exec(ctx, + `INSERT INTO media_items (content_id, type, title, status, genres) + VALUES ($1, 'series', 'HES Series', 'matched', '{}'::text[])`, + seriesID, + ); err != nil { + t.Fatalf("seed series: %v", err) + } + for i, epID := range []string{ep1, ep2} { + if _, err := pool.Exec(ctx, + `INSERT INTO episodes (content_id, series_id, season_number, episode_number, title) + VALUES ($1, $2, 1, $3, 'HES Ep')`, + epID, seriesID, i+1, + ); err != nil { + t.Fatalf("seed episode %s: %v", epID, err) + } + if _, err := pool.Exec(ctx, + `INSERT INTO episode_libraries (episode_id, media_folder_id, first_seen_at) + VALUES ($1, $2, NOW())`, + epID, folderID, + ); err != nil { + t.Fatalf("seed episode library %s: %v", epID, err) + } + } + + provider := pgstore.NewPostgresProvider(pool) + store, err := provider.ForUser(ctx, userID) + if err != nil { + t.Fatalf("store for user: %v", err) + } + base := time.Now().UTC().Add(-time.Hour) + // ep1 watched first, then ep2: most-recent-first order is ep2, ep1. + for i, epID := range []string{ep1, ep2} { + if err := store.AddHistory(ctx, userstore.WatchHistoryEntry{ + ProfileID: profileID, + MediaItemID: epID, + WatchedAt: base.Add(time.Duration(i) * time.Minute).Format(time.RFC3339), + Completed: true, + }); err != nil { + t.Fatalf("seed history %s: %v", epID, err) + } + } + + resolver := NewCatalogResolver(NewBrowseRepository(pool), NewItemRepository(pool)). + WithUserStoreProvider(provider). + WithEpisodeRepository(NewEpisodeRepository(pool)) + access := AccessFilter{UserID: userID, ProfileID: profileID} + + episodeResult, err := resolver.Resolve(ctx, CatalogRequest{ + Source: CatalogSourceHistory, + Limit: 20, + UseSourceOrder: true, + Query: QueryDefinition{MediaScope: "episode"}, + }, access) + if err != nil { + t.Fatalf("resolve episode-scoped history: %v", err) + } + if len(episodeResult.Items) != 2 { + t.Fatalf("episode-scoped history returned %d items, want 2", len(episodeResult.Items)) + } + if episodeResult.Items[0].ContentID != ep2 || episodeResult.Items[1].ContentID != ep1 { + t.Fatalf("episode-scoped history order = [%s, %s], want [%s, %s]", + episodeResult.Items[0].ContentID, episodeResult.Items[1].ContentID, ep2, ep1) + } + for _, item := range episodeResult.Items { + if item.Type != "episode" { + t.Fatalf("episode-scoped history item %s has type %q, want episode", item.ContentID, item.Type) + } + } + + seriesResult, err := resolver.Resolve(ctx, CatalogRequest{ + Source: CatalogSourceHistory, + Limit: 20, + UseSourceOrder: true, + }, access) + if err != nil { + t.Fatalf("resolve unscoped history: %v", err) + } + if len(seriesResult.Items) != 1 || seriesResult.Items[0].ContentID != seriesID { + t.Fatalf("unscoped history = %+v, want single series item %s", seriesResult.Items, seriesID) + } +}