fix(catalog): include user sort metrics
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user