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:
committed by
GitHub
co-authored by
Claude Fable 5
parent
193a2905b2
commit
f7954f226e
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user