diff --git a/internal/api/handlers/catalog.go b/internal/api/handlers/catalog.go index e92ad5d1..72cf53e4 100644 --- a/internal/api/handlers/catalog.go +++ b/internal/api/handlers/catalog.go @@ -78,12 +78,15 @@ func (h *CatalogHandler) HandleGetCatalog(w http.ResponseWriter, r *http.Request userStates := h.itemsH.listItemUserStates(r, result.Items) episodeMetadata := h.itemsH.listEpisodeBrowseMetadata(r.Context(), result.Items) sortField := catalog.NormalizeQuerySort(req.Query.Sort).Field + store, profileID, _ := h.itemsH.userStoreForRequest(r) sortMetrics := h.itemsH.listSortMetrics( r.Context(), result.Items, sortField, h.itemsH.accessFilter(r), overlaySummaries, + store, + profileID, ) items := make([]itemListResponse, 0, len(result.Items)) for _, item := range result.Items { diff --git a/internal/api/handlers/items.go b/internal/api/handlers/items.go index 6be0e103..afc5f927 100644 --- a/internal/api/handlers/items.go +++ b/internal/api/handlers/items.go @@ -847,6 +847,8 @@ func (h *ItemsHandler) listSortMetrics( sortField string, filter catalog.AccessFilter, overlaySummaries map[string]*models.OverlaySummary, + store userstore.UserStore, + profileID string, ) map[string]*sortMetricsResponse { metrics := make(map[string]*sortMetricsResponse, len(items)) switch sortField { @@ -885,10 +887,132 @@ func (h *ItemsHandler) listSortMetrics( metrics[contentID] = &sortMetricsResponse{BitrateKbps: &value} } } + case "progress", "date_viewed", "plays": + h.listUserSortMetrics(ctx, items, sortField, store, profileID, metrics) } return metrics } +func (h *ItemsHandler) listUserSortMetrics( + ctx context.Context, + items []*models.MediaItem, + sortField string, + store userstore.UserStore, + profileID string, + metrics map[string]*sortMetricsResponse, +) { + if store == nil || profileID == "" || len(items) == 0 { + return + } + contentIDs := uniqueItemContentIDs(items) + if len(contentIDs) == 0 { + return + } + + progressMap, err := store.ListProgressByMediaItems(ctx, profileID, contentIDs) + if err != nil { + return + } + + switch sortField { + case "progress": + for _, item := range items { + if item == nil || item.ContentID == "" { + continue + } + progress, ok := progressMap[item.ContentID] + if !ok || progress.Completed || progress.PositionSeconds <= 0 || progress.DurationSeconds <= 0 { + continue + } + ratio := progress.PositionSeconds / progress.DurationSeconds + metrics[item.ContentID] = &sortMetricsResponse{ProgressRatio: &ratio} + } + case "date_viewed", "plays": + history, err := listCompletedHistoryForItems(ctx, store, profileID, contentIDs) + if err != nil { + return + } + historyCounts := make(map[string]int, len(contentIDs)) + historyViewedAt := make(map[string]string, len(contentIDs)) + for _, entry := range history { + if entry.MediaItemID == "" { + continue + } + historyCounts[entry.MediaItemID]++ + if entry.WatchedAt > historyViewedAt[entry.MediaItemID] { + historyViewedAt[entry.MediaItemID] = entry.WatchedAt + } + } + for _, item := range items { + if item == nil || item.ContentID == "" { + continue + } + resp := &sortMetricsResponse{} + if sortField == "date_viewed" { + viewedAt := historyViewedAt[item.ContentID] + if progress, ok := progressMap[item.ContentID]; ok && progress.Completed && progress.UpdatedAt > viewedAt { + viewedAt = progress.UpdatedAt + } + if viewedAt == "" { + continue + } + resp.ViewedAt = viewedAt + } else { + playCount := historyCounts[item.ContentID] + if progress, ok := progressMap[item.ContentID]; ok && progress.Completed && playCount < 1 { + playCount = 1 + } + if playCount <= 0 { + continue + } + resp.PlayCount = &playCount + } + metrics[item.ContentID] = resp + } + } +} + +func uniqueItemContentIDs(items []*models.MediaItem) []string { + contentIDs := make([]string, 0, len(items)) + seen := make(map[string]struct{}, len(items)) + for _, item := range items { + if item == nil || item.ContentID == "" { + continue + } + if _, ok := seen[item.ContentID]; ok { + continue + } + seen[item.ContentID] = struct{}{} + contentIDs = append(contentIDs, item.ContentID) + } + return contentIDs +} + +func listCompletedHistoryForItems( + ctx context.Context, + store userstore.UserStore, + profileID string, + contentIDs []string, +) ([]userstore.WatchHistoryEntry, error) { + const pageSize = 500 + var all []userstore.WatchHistoryEntry + for offset := 0; ; offset += pageSize { + page, err := store.ListCompletedHistory(ctx, userstore.CompletedHistoryQuery{ + ProfileID: profileID, + MediaItemIDs: contentIDs, + Limit: pageSize, + Offset: offset, + }) + if err != nil { + return nil, err + } + all = append(all, page...) + if len(page) < pageSize { + return all, nil + } + } +} + func (h *ItemsHandler) listBrowseItemFiles(ctx context.Context, items []*models.MediaItem, filter catalog.AccessFilter) map[string][]*models.MediaFile { grouped := make(map[string][]*models.MediaFile, len(items)) if h.fileRepo == nil || len(items) == 0 { diff --git a/internal/api/handlers/items_sort_metrics_test.go b/internal/api/handlers/items_sort_metrics_test.go new file mode 100644 index 00000000..b8b77ff0 --- /dev/null +++ b/internal/api/handlers/items_sort_metrics_test.go @@ -0,0 +1,59 @@ +package handlers + +import ( + "context" + "testing" + "time" + + "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/models" + "github.com/Silo-Server/silo-server/internal/userstore" +) + +func TestListSortMetricsIncludesUserStateSorts(t *testing.T) { + ctx := context.Background() + store := newProfileTestStore(t) + items := []*models.MediaItem{ + {ContentID: "movie-progress", Type: "movie", Title: "Progress"}, + {ContentID: "movie-viewed", Type: "movie", Title: "Viewed"}, + {ContentID: "movie-plays", Type: "movie", Title: "Plays"}, + } + handler := &ItemsHandler{} + + if err := store.SetProgress(ctx, "profile-1", "movie-progress", 900, 3600, userstore.ProgressThresholds{}); err != nil { + t.Fatalf("SetProgress(progress): %v", err) + } + completedAt := time.Date(2026, 5, 29, 18, 30, 0, 0, time.UTC) + if err := store.SetProgressAt(ctx, "profile-1", "movie-viewed", 3600, 3600, true, completedAt); err != nil { + t.Fatalf("SetProgressAt(viewed): %v", err) + } + for _, watchedAt := range []string{"2026-05-27T10:00:00Z", "2026-05-28T10:00:00Z"} { + if err := store.AddHistory(ctx, userstore.WatchHistoryEntry{ + ProfileID: "profile-1", + MediaItemID: "movie-plays", + WatchedAt: watchedAt, + DurationSeconds: 3600, + Completed: true, + Source: userstore.WatchHistorySourcePlayback, + }); err != nil { + t.Fatalf("AddHistory(%s): %v", watchedAt, err) + } + } + + progressMetrics := handler.listSortMetrics(ctx, items, "progress", catalog.AccessFilter{}, nil, store, "profile-1") + progress := progressMetrics["movie-progress"] + if progress == nil || progress.ProgressRatio == nil || *progress.ProgressRatio != 0.25 { + t.Fatalf("progress metrics = %#v", progress) + } + + viewedMetrics := handler.listSortMetrics(ctx, items, "date_viewed", catalog.AccessFilter{}, nil, store, "profile-1") + if got := viewedMetrics["movie-viewed"]; got == nil || got.ViewedAt != "2026-05-29T18:30:00Z" { + t.Fatalf("viewed metrics = %#v", got) + } + + playsMetrics := handler.listSortMetrics(ctx, items, "plays", catalog.AccessFilter{}, nil, store, "profile-1") + plays := playsMetrics["movie-plays"] + if plays == nil || plays.PlayCount == nil || *plays.PlayCount != 2 { + t.Fatalf("plays metrics = %#v", plays) + } +}