* feat(collections): add profile-scoped display filters
* refactor(collections): dedup display-filter helpers per review
Address code-review feedback on the profile-scoped display filters
without changing behavior:
- Widen CompletedHistoryItemMap to accept ProgressCompletionStore and
drop the duplicate completedHistoryItemMapForProgress copy.
- Extract the duplicated MDBList candidate retry loop into a generic
collectionutil.FetchMDBListWithFallback helper, used by both the user
and library collection syncers, and cover it with unit tests.
- Reuse validateOptionalLibraryIDs in HandleUpdateCollection instead of
an inline positive-ID loop.
- Import the shared COLLECTION_{WATCH,MEDIA}_FILTER_OPTIONS in the
template config form rather than redefining them locally.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(collections): sanitize query_definition library_ids fallback
readSourceConfigLibraryIDs validated source_config.library_ids (finite,
positive, truncated, deduplicated) but returned the query_definition
fallback raw, so legacy rows could surface zero/negative/duplicate IDs
that the backend now rejects on save. Extract a shared sanitizer and
apply it to both paths.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* refactor(docs): This makes the agents annoying to work with
* Improve playback session handling
* Support collection source order in catalog filters
* fix(collections): address display filter review feedback
* refactor(catalog): remove duplicate collection query params
* Hide episode media scope for collection overlays
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
205 lines
6.1 KiB
Go
205 lines
6.1 KiB
Go
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
|
|
}
|
|
|
|
// 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
|
|
}
|