fix(catalog): resolve episode-scoped history to episode-level items (#307)

The History page's "Episodes" media filter was structurally empty: both
history read paths collapse episode watch events into their series
display item, and the resulting series ids were then filtered against
type='episode' — guaranteed zero results.

The episode media scope now resolves history at episode granularity:
- loadPersonalSourceIDs keeps each watched episode's own id (deduped to
  the most recent watch, most-recent-first) instead of collapsing to the
  series; ListFilters and SearchFacet share the loader, so facets on the
  episode-scoped history view work too.
- fetchAccessibleItemsByID gains an episode-scope path through the
  episode catalog query executor, since episode rows never hydrate from
  media_items via the browse repository.

Every other scope keeps the existing series-collapse behavior.

Fixes #299

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Quick
2026-07-05 12:04:23 -04:00
committed by GitHub
co-authored by Claude Fable 5
parent 193a2905b2
commit f7954f226e
4 changed files with 263 additions and 0 deletions
+64
View File
@@ -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 {
+23
View File
@@ -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 {
+30
View File
@@ -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)
}
}
@@ -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)
}
}