Files

228 lines
7.2 KiB
Go
Raw Permalink Normal View History

package userstore
import (
"context"
"strings"
"time"
)
type HistoryVisibilityStore interface {
VisibleHistoryTimestamps(ctx context.Context, profileID string, mediaItemIDs []string, at time.Time) (map[string]string, error)
}
type VisibleHistoryAdder interface {
AddVisibleHistory(ctx context.Context, entry WatchHistoryEntry) (WatchHistoryEntry, error)
}
func AddVisibleHistory(ctx context.Context, store UserStore, entry WatchHistoryEntry) (WatchHistoryEntry, error) {
if adder, ok := store.(VisibleHistoryAdder); ok {
return adder.AddVisibleHistory(ctx, entry)
}
entryTimes, err := VisibleHistoryTimestamps(ctx, store, entry.ProfileID, []string{entry.MediaItemID}, parseHistoryTimestamp(entry.WatchedAt))
if err != nil {
return entry, err
}
if entryTime := entryTimes[entry.MediaItemID]; entryTime != "" {
entry.WatchedAt = entryTime
}
if err := store.AddHistory(ctx, entry); err != nil {
return entry, err
}
return entry, nil
}
func VisibleHistoryTimestamps(ctx context.Context, store UserStore, profileID string, mediaItemIDs []string, at time.Time) (map[string]string, error) {
mediaItemIDs = compactHistoryMediaItemIDs(mediaItemIDs)
result := make(map[string]string, len(mediaItemIDs))
if len(mediaItemIDs) == 0 {
return result, nil
}
if visibilityStore, ok := store.(HistoryVisibilityStore); ok {
return visibilityStore.VisibleHistoryTimestamps(ctx, profileID, mediaItemIDs, at)
}
timestamp := at.UTC().Format(time.RFC3339)
if at.IsZero() {
timestamp = time.Now().UTC().Format(time.RFC3339)
}
for _, mediaItemID := range mediaItemIDs {
result[mediaItemID] = timestamp
}
return result, nil
}
func parseHistoryTimestamp(value string) time.Time {
if value == "" {
return time.Time{}
}
parsed, err := time.Parse(time.RFC3339, value)
if err != nil {
return time.Time{}
}
return parsed
}
// SeriesWatchCounts is the aggregate episode watch state for one series, as
// computed by SeriesEpisodeRollupStore.
type SeriesWatchCounts struct {
TotalEpisodes int
WatchedCount int
InProgressCount int
}
// SeriesEpisodeRollupStore is an optional store capability: compute the
// per-series episode watch-state rollup (total / watched / in-progress
// episode counts) in SQL instead of materializing every episode of every
// series and batching per-episode progress lookups through
// ListProgressWithCompletedHistory. Implemented by the Postgres store, where
// episodes and progress live in the same database; SQLite-backed stores fall
// back to the chunked in-memory path. Semantics must match
// ListProgressWithCompletedHistory + catalog.EpisodeRollupUserData: an episode
// is watched when its visible progress row is completed or a visible completed
// history row exists, and in-progress when it is not watched and its visible
// progress row has position_seconds > 0.
type SeriesEpisodeRollupStore interface {
SeriesEpisodeWatchCounts(ctx context.Context, profileID string, seriesIDs []string) (map[string]SeriesWatchCounts, error)
}
// CompletedHistoryItemMap returns the latest completed-history item row for a
// scoped item query. Lookup failures degrade to an empty map so user-data
// enrichment can keep returning progress rows.
func CompletedHistoryItemMap(ctx context.Context, store ProgressCompletionStore, query CompletedHistoryItemQuery) map[string]CompletedHistoryItem {
result := map[string]CompletedHistoryItem{}
if store == nil || query.ProfileID == "" {
return result
}
query.MediaItemIDs = compactHistoryMediaItemIDs(query.MediaItemIDs)
if len(query.MediaItemIDs) == 0 {
return result
}
items, err := store.ListCompletedHistoryItems(ctx, query)
if err != nil {
return result
}
for _, item := range items {
if item.MediaItemID != "" {
result[item.MediaItemID] = item
}
}
return result
}
// GetProgressWithCompletedHistory returns normal progress overlaid with
// completed history for callers that present a single item's played state.
func GetProgressWithCompletedHistory(ctx context.Context, store UserStore, profileID, mediaItemID string) (*WatchProgress, error) {
mediaItemID = strings.TrimSpace(mediaItemID)
if store == nil || profileID == "" || mediaItemID == "" {
return nil, nil
}
progress, err := store.GetProgress(ctx, profileID, mediaItemID)
if err != nil {
return nil, err
}
if progress != nil && progress.Completed {
return progress, nil
}
completed := CompletedHistoryItemMap(ctx, store, CompletedHistoryItemQuery{
ProfileID: profileID,
MediaItemIDs: []string{mediaItemID},
})[mediaItemID]
if completed.MediaItemID == "" {
return progress, nil
}
if progress == nil {
return &WatchProgress{
ProfileID: profileID,
MediaItemID: mediaItemID,
Completed: true,
UpdatedAt: completed.WatchedAt,
}, nil
}
progress.Completed = true
if timestampAfter(completed.WatchedAt, progress.UpdatedAt) {
progress.UpdatedAt = completed.WatchedAt
}
return progress, nil
}
// ListProgressWithCompletedHistory returns progress for mediaItemIDs with
// completed history folded into the map. History is only queried for IDs that
// are not already completed by a progress row.
func ListProgressWithCompletedHistory(ctx context.Context, store ProgressCompletionStore, profileID string, mediaItemIDs []string) (map[string]WatchProgress, error) {
mediaItemIDs = compactHistoryMediaItemIDs(mediaItemIDs)
if store == nil || profileID == "" || len(mediaItemIDs) == 0 {
return map[string]WatchProgress{}, nil
}
progressMap, err := store.ListProgressByMediaItems(ctx, profileID, mediaItemIDs)
if err != nil {
return nil, err
}
if progressMap == nil {
progressMap = map[string]WatchProgress{}
}
candidates := make([]string, 0, len(mediaItemIDs))
for _, mediaItemID := range mediaItemIDs {
if progress, ok := progressMap[mediaItemID]; ok && progress.Completed {
continue
}
candidates = append(candidates, mediaItemID)
}
if len(candidates) == 0 {
return progressMap, nil
}
completed := CompletedHistoryItemMap(ctx, store, CompletedHistoryItemQuery{
ProfileID: profileID,
MediaItemIDs: candidates,
})
for mediaItemID, completedItem := range completed {
if progress, ok := progressMap[mediaItemID]; ok {
progress.Completed = true
if timestampAfter(completedItem.WatchedAt, progress.UpdatedAt) {
progress.UpdatedAt = completedItem.WatchedAt
}
progressMap[mediaItemID] = progress
continue
}
progressMap[mediaItemID] = WatchProgress{
ProfileID: profileID,
MediaItemID: mediaItemID,
Completed: true,
UpdatedAt: completedItem.WatchedAt,
}
}
return progressMap, nil
}
func compactHistoryMediaItemIDs(mediaItemIDs []string) []string {
result := make([]string, 0, len(mediaItemIDs))
seen := make(map[string]struct{}, len(mediaItemIDs))
for _, mediaItemID := range mediaItemIDs {
mediaItemID = strings.TrimSpace(mediaItemID)
if mediaItemID == "" {
continue
}
if _, ok := seen[mediaItemID]; ok {
continue
}
seen[mediaItemID] = struct{}{}
result = append(result, mediaItemID)
}
return result
}
func timestampAfter(left, right string) bool {
if left == "" {
return false
}
if right == "" {
return true
}
leftTime, leftErr := time.Parse(time.RFC3339, left)
rightTime, rightErr := time.Parse(time.RFC3339, right)
if leftErr == nil && rightErr == nil {
return leftTime.After(rightTime)
}
return left > right
}