feat(podcasts): smart episode collections — ALL/ANY match modes + episode rule fields (item 2.4)
The daily briefing needs AND: today's rules engine was OR-only. The
match semantics become a per-collection column
(collections.rule_match_mode, 'any' legacy default | 'all'), flipped on
its own endpoint (PUT /api/collections/:id/match-mode, owner-only —
nothing else on the row changes) and surfaced in the rules page as an
ANY/ALL toggle with matching copy.
Episode rule fields join the vocabulary (plan item 2.4): feed (the
multi-select rides the new `in` operator — comma-separated feed ids),
played (the canonical finished predicate — viewer progress ≥ 0.999,
same as hide_finished), downloaded, published_within (N days), plus
title/description contains. The rules candidate query now carries the
viewer's progress (LEFT JOIN reading_progress) so played/unplayed is
per-user, and TestRules/preview speak the SAME evaluator: the handler's
duplicated field switch is deleted and delegates to
CollectionService.FieldMatches — one vocabulary, one semantics.
Legitimate semantics note: in ANY mode `played:false` matches unplayed
BOOKS too (everything with no progress row) — correct per-rule
behavior, and exactly why the briefing mode exists.
Backward compatibility: every legacy row defaults to 'any' (the old
OR), UpdateCollection keeps the stored mode when the form doesn't send
one, and the evaluator treats absent data honestly (no date → no
match; no progress → unplayed).
Pinned by TestPodcastSmartCollections: the AND-mode briefing
(feed∈{A} ∧ unplayed ∧ <7d = exactly the new episode), OR-mode
widening, owner-only flips, the preview honoring the mode, and
downloaded/contains rules. Also fixes a latent bug the new query
surfaced: UpdateCollectionViewSettings referenced a nonexistent
updated_at column (never exercised until now — the new sibling query
was written without it).
This commit is contained in:
1 parent
f68fb19afb
commit
abb7c84833
17 files changed
+694
-204
No files matched your search
@@ -6,7 +6,9 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
@@ -32,14 +34,32 @@ type Rule struct {
|
||||
|
||||
// Known rule fields and operators — the rules builder UI and the
|
||||
// persisted JSONB documents must agree with this vocabulary.
|
||||
// Episode fields (plan item 2.4): feed/downloaded/published_within ride
|
||||
// the media row; played reads the VIEWER's progress (the candidate
|
||||
// query carries it — SmartEvalOptions) so "unplayed" is per-user.
|
||||
var ruleFields = map[string]bool{
|
||||
"genre": true, "series": true, "author": true, "language": true,
|
||||
"publisher": true, "copyright_year": true, "tags": true,
|
||||
// Episode collections (plan item 2.4):
|
||||
"title": true, "description": true,
|
||||
"feed": true, "played": true, "downloaded": true, "published_within": true,
|
||||
}
|
||||
|
||||
var ruleOperators = map[string]bool{
|
||||
"equals": true, "not_equals": true, "contains": true, "not_contains": true,
|
||||
"starts_with": true, "ends_with": true, "greater_than": true, "less_than": true,
|
||||
"in": true, // multi-value (comma-separated) — the feed multi-select
|
||||
}
|
||||
|
||||
// Smart-collection match semantics (plan item 2.4): 'any' = OR (the
|
||||
// legacy behavior), 'all' = AND (the daily briefing).
|
||||
const (
|
||||
RuleMatchAny = "any"
|
||||
RuleMatchAll = "all"
|
||||
)
|
||||
|
||||
func ValidRuleMatchMode(m string) bool {
|
||||
return m == RuleMatchAny || m == RuleMatchAll
|
||||
}
|
||||
|
||||
// RuleValidationError reports a rule payload the evaluator could never
|
||||
@@ -133,13 +153,17 @@ func (s *CollectionService) GetUserCollections(ctx context.Context, userID uuid.
|
||||
}
|
||||
|
||||
// UpdateCollection updates a collection
|
||||
func (s *CollectionService) UpdateCollection(ctx context.Context, collectionID uuid.UUID, name, description, color, icon string, autoAssignRules []Rule, viewSettings map[string]any) (database.Collections, error) {
|
||||
// UpdateCollection rewrites the collection row. ruleMatchMode empty or
|
||||
// invalid = keep the stored value (the edit form only sends the toggles
|
||||
// it renders — plan item 2.4).
|
||||
func (s *CollectionService) UpdateCollection(ctx context.Context, collectionID uuid.UUID, name, description, color, icon string, autoAssignRules []Rule, viewSettings map[string]any, ruleMatchMode string) (database.Collections, error) {
|
||||
// A2c §3.3: the edit form carries only the toggles it renders — merge
|
||||
// them OVER the stored view_settings so provenance keys
|
||||
// (imported_from_series) and anything else on the row survive.
|
||||
if len(viewSettings) > 0 {
|
||||
existing, err := s.db.GetCollection(ctx, pgtype.UUID{Bytes: collectionID, Valid: true})
|
||||
if err == nil && len(existing.ViewSettings) > 0 {
|
||||
existingMode := RuleMatchAny
|
||||
if existing, err := s.db.GetCollection(ctx, pgtype.UUID{Bytes: collectionID, Valid: true}); err == nil {
|
||||
existingMode = existing.RuleMatchMode
|
||||
if len(viewSettings) > 0 && len(existing.ViewSettings) > 0 {
|
||||
var stored map[string]any
|
||||
if json.Unmarshal(existing.ViewSettings, &stored) == nil {
|
||||
merged := stored
|
||||
@@ -150,6 +174,9 @@ func (s *CollectionService) UpdateCollection(ctx context.Context, collectionID u
|
||||
}
|
||||
}
|
||||
}
|
||||
if !ValidRuleMatchMode(ruleMatchMode) {
|
||||
ruleMatchMode = existingMode
|
||||
}
|
||||
|
||||
// Convert rules to JSONB ([]byte)
|
||||
var rulesJSON []byte
|
||||
@@ -179,6 +206,7 @@ func (s *CollectionService) UpdateCollection(ctx context.Context, collectionID u
|
||||
Icon: pgtype.Text{String: icon, Valid: icon != ""},
|
||||
AutoAssignRules: rulesJSON,
|
||||
ViewSettings: settingsJSON,
|
||||
RuleMatchMode: ruleMatchMode,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
@@ -423,6 +451,14 @@ type SmartEvalOptions struct {
|
||||
// VisibleIDs is the requesting user's visible-library set (family
|
||||
// lens). Empty means no visible-library filter.
|
||||
VisibleIDs []pgtype.UUID
|
||||
// MatchMode is the collection's rule_match_mode: "any" (OR —
|
||||
// legacy default) or "all" (AND — the daily briefing). Empty
|
||||
// resolves to "any".
|
||||
MatchMode string
|
||||
// ViewerUserID scopes the candidate query's progress join — the
|
||||
// played/unplayed rule is per-user. Zero UUID = no viewer (played
|
||||
// rules then see "unplayed" for everything, honestly).
|
||||
ViewerUserID pgtype.UUID
|
||||
}
|
||||
|
||||
// EvaluateSmartCollection resolves a smart collection's membership LIVE:
|
||||
@@ -441,36 +477,112 @@ func (s *CollectionService) EvaluateSmartCollection(ctx context.Context, rules [
|
||||
items, err := s.db.GetVisibleLiveMediaForRules(ctx, database.GetVisibleLiveMediaForRulesParams{
|
||||
LibraryID: opts.LibraryID,
|
||||
VisibleLibraryIds: visibleArg,
|
||||
ViewerUserID: opts.ViewerUserID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load candidate books: %w", err)
|
||||
}
|
||||
|
||||
mode := opts.MatchMode
|
||||
if !ValidRuleMatchMode(mode) {
|
||||
mode = RuleMatchAny
|
||||
}
|
||||
matches := make([]database.MediaItems, 0, len(items))
|
||||
for _, item := range items {
|
||||
if s.mediaItemMatchesAnyRule(item, enabled) {
|
||||
matches = append(matches, item)
|
||||
for _, row := range items {
|
||||
if s.mediaItemMatches(mode, row.MediaItems, row.ViewerPercentage, enabled) {
|
||||
matches = append(matches, row.MediaItems)
|
||||
}
|
||||
}
|
||||
return matches, nil
|
||||
}
|
||||
|
||||
func (s *CollectionService) mediaItemMatchesAnyRule(item database.MediaItems, rules []Rule) bool {
|
||||
// mediaItemMatches evaluates the enabled rules under the collection's
|
||||
// match mode: "any" (OR — legacy) or "all" (AND — the briefing).
|
||||
// viewerPercentage is the requesting user's progress for this item
|
||||
// (0 when none) — the played/unplayed episode rule reads it.
|
||||
func (s *CollectionService) mediaItemMatches(mode string, item database.MediaItems, viewerPercentage float64, rules []Rule) bool {
|
||||
if len(rules) == 0 {
|
||||
return false
|
||||
}
|
||||
if mode == RuleMatchAll {
|
||||
for _, rule := range rules {
|
||||
if !s.mediaItemMatchesRule(item, viewerPercentage, rule) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
for _, rule := range rules {
|
||||
if s.mediaItemMatchesRule(item, rule) {
|
||||
if s.mediaItemMatchesRule(item, viewerPercentage, rule) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *CollectionService) mediaItemMatchesRule(item database.MediaItems, rule Rule) bool {
|
||||
// mediaItemMatchesAnyRule is the legacy OR path, kept for callers that
|
||||
// predate match modes.
|
||||
func (s *CollectionService) mediaItemMatchesAnyRule(item database.MediaItems, rules []Rule) bool {
|
||||
return s.mediaItemMatches(RuleMatchAny, item, 0, rules)
|
||||
}
|
||||
|
||||
// episodeFinished is THE canonical finished definition (same predicate
|
||||
// as hide_finished): viewer progress percentage >= 0.999.
|
||||
func episodeFinished(viewerPercentage float64) bool {
|
||||
return viewerPercentage >= 0.999
|
||||
}
|
||||
|
||||
// mediaItemMatchesRule evaluates one rule against one item.
|
||||
func (s *CollectionService) mediaItemMatchesRule(item database.MediaItems, viewerPercentage float64, rule Rule) bool {
|
||||
if rule.Enabled != nil && !*rule.Enabled {
|
||||
return false
|
||||
}
|
||||
return s.FieldMatches(item, viewerPercentage, rule.Field, rule.Operator, rule.Value)
|
||||
}
|
||||
|
||||
// FieldMatches is THE single rule evaluator (the rules-builder preview
|
||||
// delegates here too — one vocabulary, one semantics). Episode/user-state
|
||||
// fields (feed/played/downloaded/published_within) evaluate directly;
|
||||
// everything else extracts a string and hits the generic comparator.
|
||||
func (s *CollectionService) FieldMatches(item database.MediaItems, viewerPercentage float64, field, operator, value string) bool {
|
||||
switch field {
|
||||
case "feed":
|
||||
if !item.SourceFeedID.Valid {
|
||||
return false
|
||||
}
|
||||
feedID := uuid.UUID(item.SourceFeedID.Bytes).String()
|
||||
for _, raw := range strings.Split(value, ",") {
|
||||
if strings.TrimSpace(raw) == feedID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
case "played":
|
||||
wantPlayed := value == "true"
|
||||
return episodeFinished(viewerPercentage) == wantPlayed
|
||||
case "downloaded":
|
||||
wantDownloaded := value == "true"
|
||||
return (item.DownloadState == "downloaded") == wantDownloaded
|
||||
case "published_within":
|
||||
if !item.DatePublished.Valid {
|
||||
return false
|
||||
}
|
||||
days, err := strconv.Atoi(strings.TrimSpace(value))
|
||||
if err != nil || days < 0 {
|
||||
return false
|
||||
}
|
||||
cutoff := time.Now().UTC().AddDate(0, 0, -days)
|
||||
return item.DatePublished.Time.After(cutoff)
|
||||
}
|
||||
|
||||
var fieldValue string
|
||||
switch rule.Field {
|
||||
switch field {
|
||||
case "title":
|
||||
fieldValue = item.Title
|
||||
case "description":
|
||||
if item.Description.Valid {
|
||||
fieldValue = item.Description.String
|
||||
}
|
||||
case "genre":
|
||||
if item.Genre.Valid {
|
||||
fieldValue = item.Genre.String
|
||||
@@ -501,11 +613,14 @@ func (s *CollectionService) mediaItemMatchesRule(item database.MediaItems, rule
|
||||
}
|
||||
}
|
||||
|
||||
return s.evaluateRule(fieldValue, rule.Operator, rule.Value)
|
||||
return s.evaluateRule(fieldValue, operator, value)
|
||||
}
|
||||
|
||||
// EvaluateRules evaluates auto-assign rules for a media item
|
||||
func (s *CollectionService) EvaluateRules(mediaItem database.MediaItems, rules []Rule) []RuleEvaluation {
|
||||
// EvaluateRules evaluates auto-assign rules for a media item — the
|
||||
// rules-builder preview's per-rule view. viewerPercentage is the
|
||||
// requesting user's progress for this item (0 = none), so played /
|
||||
// unplayed rules preview honestly (plan item 2.4).
|
||||
func (s *CollectionService) EvaluateRules(mediaItem database.MediaItems, viewerPercentage float64, rules []Rule) []RuleEvaluation {
|
||||
var evaluations []RuleEvaluation
|
||||
|
||||
// Sort rules by priority (higher priority first)
|
||||
@@ -527,40 +642,7 @@ func (s *CollectionService) EvaluateRules(mediaItem database.MediaItems, rules [
|
||||
}
|
||||
eval := RuleEvaluation{
|
||||
RuleID: rule.ID,
|
||||
Matches: false,
|
||||
}
|
||||
|
||||
switch rule.Field {
|
||||
case "genre":
|
||||
if mediaItem.Genre.Valid {
|
||||
eval.Matches = s.evaluateRule(mediaItem.Genre.String, rule.Operator, rule.Value)
|
||||
}
|
||||
case "series":
|
||||
if mediaItem.Series.Valid {
|
||||
eval.Matches = s.evaluateRule(mediaItem.Series.String, rule.Operator, rule.Value)
|
||||
}
|
||||
case "author":
|
||||
if mediaItem.Author.Valid {
|
||||
eval.Matches = s.evaluateRule(mediaItem.Author.String, rule.Operator, rule.Value)
|
||||
}
|
||||
case "language":
|
||||
if mediaItem.Language.Valid {
|
||||
eval.Matches = s.evaluateRule(mediaItem.Language.String, rule.Operator, rule.Value)
|
||||
}
|
||||
case "publisher":
|
||||
if mediaItem.Publisher.Valid {
|
||||
eval.Matches = s.evaluateRule(mediaItem.Publisher.String, rule.Operator, rule.Value)
|
||||
}
|
||||
case "copyright_year":
|
||||
if mediaItem.CopyrightYear.Valid {
|
||||
yearStr := fmt.Sprintf("%d", mediaItem.CopyrightYear.Int32)
|
||||
eval.Matches = s.evaluateRule(yearStr, rule.Operator, rule.Value)
|
||||
}
|
||||
case "tags":
|
||||
if len(mediaItem.Tags) > 0 {
|
||||
tagsStr := strings.Join(mediaItem.Tags, ", ")
|
||||
eval.Matches = s.evaluateRule(tagsStr, rule.Operator, rule.Value)
|
||||
}
|
||||
Matches: s.FieldMatches(mediaItem, viewerPercentage, rule.Field, rule.Operator, rule.Value),
|
||||
}
|
||||
|
||||
// Calculate confidence based on rule type and priority
|
||||
|
||||
Reference in new issue
Block a user