Files
silo-server/internal/catalog/history_display.go
f7954f226e 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>
2026-07-05 12:04:23 -04:00

82 lines
2.3 KiB
Go

package catalog
import (
"context"
"strings"
"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 {
episodeIDs := make([]string, 0, len(entries))
seenEpisodeIDs := make(map[string]struct{}, len(entries))
for _, entry := range entries {
mediaItemID := strings.TrimSpace(entry.MediaItemID)
if mediaItemID == "" {
continue
}
if _, ok := seenEpisodeIDs[mediaItemID]; ok {
continue
}
seenEpisodeIDs[mediaItemID] = struct{}{}
episodeIDs = append(episodeIDs, mediaItemID)
}
episodes, err := episodeRepo.GetByIDs(ctx, episodeIDs)
if err != nil {
return nil, err
}
for _, episode := range episodes {
if episode == nil {
continue
}
if seriesID := strings.TrimSpace(episode.SeriesID); seriesID != "" {
episodeSeriesByID[episode.ContentID] = seriesID
}
}
}
ids := make([]string, 0, len(entries))
seen := make(map[string]struct{}, len(entries))
for _, entry := range entries {
displayID := strings.TrimSpace(entry.MediaItemID)
if seriesID, ok := episodeSeriesByID[displayID]; ok {
displayID = seriesID
}
if displayID == "" {
continue
}
if _, ok := seen[displayID]; ok {
continue
}
seen[displayID] = struct{}{}
ids = append(ids, displayID)
}
return ids, nil
}