"Newest episodes from my selected podcasts" is now buildable in the UI, end to end — the user could not find ANY podcast-related filter surface because none existed: the New Collection modal could only create book-scope smart collections, and the engine's episodes scope (item 3.1) had no authoring door. The scope door: - The create modal's Smart card gains a Rules-scope segmented control (📚 Books / 🎧 Podcast episodes); the copy no longer claims smart means "match books by rules". Choosing Episodes seeds an EMPTY episodes tree ({"scope":"episodes","mode":"all","children":[]}) so the collection is born episode-scoped: the editor opens with the episode vocabulary (feed/downloaded/played/published date) and membership runs the episode engine from the first rule. - ParseFilterTree now accepts an EMPTY ROOT (nested empty groups stay invalid) — the seed's legal shape. The root arrives at depth 1 by call convention, hence the depth>1 exemption. - The composers define the empty-tree semantics: zero children matches NOTHING (ComposedLiveMedia/ComposedCount guards), mirroring EvaluateSmartCollection's zero-enabled-rules convention. Without the guard the composed query degrades to the bare scope head and a fresh collection would swallow every episode in scope — pinned by TestEpisodeCollectionsEmptySeedMatchesNothing (envelope book_count 0) and TestEmptyRootTreeSeedsEpisodesScope. The curated chain order (plan §10 R2, user need: the briefing plays NEWEST-first): - view_settings.chain_order ("native"/"newest"/"oldest"/"reverse") stored on the collection — curated content, not a player-side knob. - PUT /api/collections/:id/view-settings merges the key into the stored JSONB (a blind write would clobber the playlist toggles). - buildSmartCollectionSequenceContext routes OrderEpisodes through handlers.CollectionChainOrder; the detail header's Play-as-Sequence gains the order select next to it (PlayCollectionButton), which PUTs and reloads. Pinned by chain_follows_the_curated_order (reverse flips the chain indexes and hops). Caught by the new test, a latent clobber fixed: UpdateCollection treated a NIL view_settings payload as "write NULL" — any edit that carried no settings dropped every stored key (chain_order, playlist toggles from API clients, imported provenance). Nil now means untouched, the same convention filterTree already had. Composer guards also cover ComposedCount, so the builder's live count reads the same semantics. Tests: collectionChainOrder unit matrix, empty-root parse unit pin, two integration pins (seed matches nothing; curated order drives the chain and survives whole-map edits).
1060 lines
34 KiB
Go
1060 lines
34 KiB
Go
package services
|
|
|
|
import (
|
|
"bookhoard/internal/database"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgconn"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
)
|
|
|
|
// ErrCollectionNameTaken: the user already has a collection with this name.
|
|
var ErrCollectionNameTaken = errors.New("collection name already exists")
|
|
|
|
// Rule represents an auto-assign rule for collections
|
|
type Rule struct {
|
|
ID string `json:"id"`
|
|
Field string `json:"field"` // "genre", "series", "author", "language", "publisher", "copyright_year", "tags"
|
|
Operator string `json:"operator"` // "equals", "contains", "starts_with", "ends_with", "greater_than", "less_than"
|
|
Value string `json:"value"`
|
|
Priority int `json:"priority"` // 1-10, higher values take precedence
|
|
// Enabled gates the rule in the LIVE evaluation used by smart
|
|
// collections; absent (JSON omitempty) means enabled — rules written
|
|
// before the field existed keep matching.
|
|
Enabled *bool `json:"enabled,omitempty"`
|
|
}
|
|
|
|
// 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
|
|
// honor (unknown field/operator, empty value).
|
|
type RuleValidationError struct{ Msg string }
|
|
|
|
func (e *RuleValidationError) Error() string { return e.Msg }
|
|
|
|
// RuleEvaluation represents the result of evaluating a rule
|
|
type RuleEvaluation struct {
|
|
RuleID string `json:"rule_id"`
|
|
Matches bool `json:"matches"`
|
|
Confidence float64 `json:"confidence"`
|
|
}
|
|
|
|
// CollectionService handles collection management
|
|
type CollectionService struct {
|
|
db *database.Queries
|
|
// search powers the unified-engine path (item 3.2): tree-driven
|
|
// smart collections evaluate via ComposedLiveMedia. Optional —
|
|
// nil disables tree evaluation (legacy-only services, which keeps
|
|
// every existing constructor call site valid).
|
|
search *SearchService
|
|
}
|
|
|
|
// NewCollectionService creates a new collection service. search is
|
|
// optional (variadic for constructor compatibility) — required for
|
|
// TREE-driven smart collections (item 3.2), ignored otherwise.
|
|
func NewCollectionService(db *database.Queries, search ...*SearchService) *CollectionService {
|
|
svc := &CollectionService{db: db}
|
|
if len(search) > 0 {
|
|
svc.search = search[0]
|
|
}
|
|
return svc
|
|
}
|
|
|
|
// CreateCollection creates a new collection. filterTree (item 3.2)
|
|
// replaces autoAssignRules as the smart engine's source when present —
|
|
// both may coexist on the row (tree wins at evaluation).
|
|
func (s *CollectionService) CreateCollection(ctx context.Context, userID uuid.UUID, name, description, color, icon string, autoAssignRules []Rule, viewSettings map[string]any, filterTree []byte) (database.Collections, error) {
|
|
// Convert rules to JSONB ([]byte)
|
|
var rulesJSON []byte
|
|
if len(autoAssignRules) > 0 {
|
|
var err error
|
|
rulesJSON, err = json.Marshal(autoAssignRules)
|
|
if err != nil {
|
|
return database.Collections{}, fmt.Errorf("failed to marshal auto-assign rules: %v", err)
|
|
}
|
|
}
|
|
|
|
// Convert view settings to JSONB ([]byte)
|
|
var settingsJSON []byte
|
|
if len(viewSettings) > 0 {
|
|
var err error
|
|
settingsJSON, err = json.Marshal(viewSettings)
|
|
if err != nil {
|
|
return database.Collections{}, fmt.Errorf("failed to marshal view settings: %v", err)
|
|
}
|
|
}
|
|
|
|
collection, err := s.db.CreateCollection(ctx, database.CreateCollectionParams{
|
|
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
|
Name: name,
|
|
Description: pgtype.Text{String: description, Valid: description != ""},
|
|
Color: pgtype.Text{String: color, Valid: color != ""},
|
|
Icon: pgtype.Text{String: icon, Valid: icon != ""},
|
|
AutoAssignRules: rulesJSON,
|
|
ViewSettings: settingsJSON,
|
|
FilterTree: filterTree,
|
|
})
|
|
|
|
if err != nil {
|
|
// Duplicate name (UNIQUE(user_id, name)) is a client error, not a
|
|
// server failure: surface it as 409 so callers can recover.
|
|
var pgErr *pgconn.PgError
|
|
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
|
return database.Collections{}, ErrCollectionNameTaken
|
|
}
|
|
return database.Collections{}, fmt.Errorf("failed to create collection: %w", err)
|
|
}
|
|
|
|
return collection, nil
|
|
}
|
|
|
|
// GetCollection gets a collection by ID
|
|
func (s *CollectionService) GetCollection(ctx context.Context, collectionID uuid.UUID) (database.Collections, error) {
|
|
collection, err := s.db.GetCollection(ctx, pgtype.UUID{Bytes: collectionID, Valid: true})
|
|
if err != nil {
|
|
return database.Collections{}, fmt.Errorf("failed to get collection: %v", err)
|
|
}
|
|
|
|
return collection, nil
|
|
}
|
|
|
|
// GetUserCollections gets all collections for a user
|
|
func (s *CollectionService) GetUserCollections(ctx context.Context, userID uuid.UUID) ([]database.Collections, error) {
|
|
collections, err := s.db.GetCollectionsByUser(ctx, pgtype.UUID{Bytes: userID, Valid: true})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get user collections: %v", err)
|
|
}
|
|
|
|
return collections, nil
|
|
}
|
|
|
|
// UpdateCollection updates a collection
|
|
// 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). filterTree semantics: nil = leave the
|
|
// stored tree untouched.
|
|
func (s *CollectionService) UpdateCollection(ctx context.Context, collectionID uuid.UUID, name, description, color, icon string, autoAssignRules []Rule, viewSettings map[string]any, ruleMatchMode string, filterTree []byte) (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.
|
|
existingMode := RuleMatchAny
|
|
if existing, err := s.db.GetCollection(ctx, pgtype.UUID{Bytes: collectionID, Valid: true}); err == nil {
|
|
existingMode = existing.RuleMatchMode
|
|
if filterTree == nil {
|
|
filterTree = existing.FilterTree // nil = untouched (forms don't send trees)
|
|
}
|
|
if len(viewSettings) > 0 && len(existing.ViewSettings) > 0 {
|
|
var stored map[string]any
|
|
if json.Unmarshal(existing.ViewSettings, &stored) == nil {
|
|
merged := stored
|
|
for k, v := range viewSettings {
|
|
merged[k] = v
|
|
}
|
|
viewSettings = merged
|
|
}
|
|
}
|
|
// Same convention as filterTree: a NIL view_settings payload
|
|
// means untouched — keep the stored doc. Without this, an edit
|
|
// that carries no settings wrote NULL over the row and silently
|
|
// dropped every stored key (chain_order, imported provenance);
|
|
// the 2026-10-31 chain-order test pinned exactly this clobber.
|
|
if viewSettings == nil && len(existing.ViewSettings) > 0 {
|
|
var stored map[string]any
|
|
if json.Unmarshal(existing.ViewSettings, &stored) == nil && len(stored) > 0 {
|
|
viewSettings = stored
|
|
}
|
|
}
|
|
}
|
|
if !ValidRuleMatchMode(ruleMatchMode) {
|
|
ruleMatchMode = existingMode
|
|
}
|
|
|
|
// Convert rules to JSONB ([]byte)
|
|
var rulesJSON []byte
|
|
if len(autoAssignRules) > 0 {
|
|
var err error
|
|
rulesJSON, err = json.Marshal(autoAssignRules)
|
|
if err != nil {
|
|
return database.Collections{}, fmt.Errorf("failed to marshal auto-assign rules: %v", err)
|
|
}
|
|
}
|
|
|
|
// Convert view settings to JSONB ([]byte)
|
|
var settingsJSON []byte
|
|
if len(viewSettings) > 0 {
|
|
var err error
|
|
settingsJSON, err = json.Marshal(viewSettings)
|
|
if err != nil {
|
|
return database.Collections{}, fmt.Errorf("failed to marshal view settings: %v", err)
|
|
}
|
|
}
|
|
|
|
collection, err := s.db.UpdateCollection(ctx, database.UpdateCollectionParams{
|
|
ID: pgtype.UUID{Bytes: collectionID, Valid: true},
|
|
Name: name,
|
|
Description: pgtype.Text{String: description, Valid: description != ""},
|
|
Color: pgtype.Text{String: color, Valid: color != ""},
|
|
Icon: pgtype.Text{String: icon, Valid: icon != ""},
|
|
AutoAssignRules: rulesJSON,
|
|
ViewSettings: settingsJSON,
|
|
RuleMatchMode: ruleMatchMode,
|
|
FilterTree: filterTree,
|
|
})
|
|
|
|
if err != nil {
|
|
return database.Collections{}, fmt.Errorf("failed to update collection: %v", err)
|
|
}
|
|
|
|
return collection, nil
|
|
}
|
|
|
|
// DeleteCollection deletes a collection
|
|
func (s *CollectionService) DeleteCollection(ctx context.Context, collectionID uuid.UUID) error {
|
|
// System collections (the 5 dashboard sections) are PERMANENT: a
|
|
// delete tombstones the row (invisible everywhere, restorable with
|
|
// the same id) instead of destroying it and every reference to it.
|
|
row, err := s.db.GetCollection(ctx, pgtype.UUID{Bytes: collectionID, Valid: true})
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil // already gone: keep delete idempotent
|
|
}
|
|
return fmt.Errorf("failed to fetch collection: %w", err)
|
|
}
|
|
|
|
if row.IsSystemCollection.Bool {
|
|
return s.db.TombstoneSystemCollection(ctx, pgtype.UUID{Bytes: collectionID, Valid: true})
|
|
}
|
|
|
|
err = s.db.DeleteCollection(ctx, pgtype.UUID{Bytes: collectionID, Valid: true})
|
|
if err != nil {
|
|
return fmt.Errorf("failed to delete collection: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// AddBookToCollection adds a book to a collection
|
|
func (s *CollectionService) AddBookToCollection(ctx context.Context, collectionID, mediaItemID, userID uuid.UUID) error {
|
|
_, err := s.db.AddBookToCollection(ctx, database.AddBookToCollectionParams{
|
|
CollectionID: pgtype.UUID{Bytes: collectionID, Valid: true},
|
|
MediaItemID: pgtype.UUID{Bytes: mediaItemID, Valid: true},
|
|
AddedByUserID: pgtype.UUID{Bytes: userID, Valid: true},
|
|
})
|
|
|
|
if err != nil {
|
|
return fmt.Errorf("failed to add book to collection: %v", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// RemoveBookFromCollection removes a book from a collection
|
|
func (s *CollectionService) RemoveBookFromCollection(ctx context.Context, collectionID, mediaItemID uuid.UUID) error {
|
|
err := s.db.RemoveBookFromCollection(ctx, database.RemoveBookFromCollectionParams{
|
|
CollectionID: pgtype.UUID{Bytes: collectionID, Valid: true},
|
|
MediaItemID: pgtype.UUID{Bytes: mediaItemID, Valid: true},
|
|
})
|
|
|
|
if err != nil {
|
|
return fmt.Errorf("failed to remove book from collection: %v", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetCollectionBooks gets all books in a collection
|
|
func (s *CollectionService) GetCollectionBooks(ctx context.Context, collectionID uuid.UUID) ([]database.GetCollectionItemsRow, error) {
|
|
books, err := s.db.GetCollectionItems(ctx, pgtype.UUID{Bytes: collectionID, Valid: true})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get collection books: %v", err)
|
|
}
|
|
|
|
return books, nil
|
|
}
|
|
|
|
// GetBookCollections gets all collections that contain a specific book
|
|
func (s *CollectionService) GetBookCollections(ctx context.Context, mediaItemID uuid.UUID) ([]database.Collections, error) {
|
|
collections, err := s.db.GetCollectionsForBook(ctx, pgtype.UUID{Bytes: mediaItemID, Valid: true})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get book collections: %v", err)
|
|
}
|
|
|
|
return collections, nil
|
|
}
|
|
|
|
// ParseRules decodes a collection's auto_assign_rules JSONB into typed
|
|
// rules. "null", empty, and malformed docs mean "no rules".
|
|
func (s *CollectionService) ParseRules(raw []byte) []Rule {
|
|
if len(raw) == 0 {
|
|
return nil
|
|
}
|
|
var rules []Rule
|
|
if err := json.Unmarshal(raw, &rules); err != nil {
|
|
return nil
|
|
}
|
|
return rules
|
|
}
|
|
|
|
// IsSmartCollection derives the collection type: a non-empty ruleset
|
|
// makes it SMART (live-matched), an empty/absent ruleset makes it STATIC
|
|
// (stored collection_items links).
|
|
func (s *CollectionService) IsSmartCollection(raw []byte) bool {
|
|
return len(s.ParseRules(raw)) > 0
|
|
}
|
|
|
|
// EnabledRules filters a ruleset down to the rules that participate in
|
|
// live evaluation (absent enabled = enabled for backward compatibility).
|
|
func (s *CollectionService) EnabledRules(rules []Rule) []Rule {
|
|
enabled := make([]Rule, 0, len(rules))
|
|
for _, rule := range rules {
|
|
if rule.Enabled == nil || *rule.Enabled {
|
|
enabled = append(enabled, rule)
|
|
}
|
|
}
|
|
return enabled
|
|
}
|
|
|
|
// validateRule checks a rule payload the evaluator will have to honor.
|
|
func (s *CollectionService) validateRule(rule Rule) error {
|
|
if !ruleFields[rule.Field] {
|
|
return &RuleValidationError{Msg: "unknown rule field: " + rule.Field}
|
|
}
|
|
if !ruleOperators[rule.Operator] {
|
|
return &RuleValidationError{Msg: "unknown rule operator: " + rule.Operator}
|
|
}
|
|
if strings.TrimSpace(rule.Value) == "" {
|
|
return &RuleValidationError{Msg: "rule value is required"}
|
|
}
|
|
if rule.Priority < 0 || rule.Priority > 10 {
|
|
return &RuleValidationError{Msg: "priority must be between 0 and 10"}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// AddRule appends a rule to the collection's ruleset, generating an id
|
|
// when the caller did not supply one. Adding the first rule CONVERTS a
|
|
// static collection to smart (type is derived from rules presence).
|
|
func (s *CollectionService) AddRule(ctx context.Context, collectionID uuid.UUID, rule Rule) (database.Collections, Rule, error) {
|
|
if err := s.validateRule(rule); err != nil {
|
|
return database.Collections{}, Rule{}, err
|
|
}
|
|
|
|
collection, err := s.GetCollection(ctx, collectionID)
|
|
if err != nil {
|
|
return database.Collections{}, Rule{}, err
|
|
}
|
|
|
|
rules := s.ParseRules(collection.AutoAssignRules)
|
|
if rule.ID == "" {
|
|
rule.ID = uuid.NewString()
|
|
}
|
|
rules = append(rules, rule)
|
|
|
|
rulesJSON, marshalErr := json.Marshal(rules)
|
|
if marshalErr != nil {
|
|
return database.Collections{}, Rule{}, fmt.Errorf("failed to marshal rules: %w", marshalErr)
|
|
}
|
|
|
|
updated, err := s.db.UpdateCollectionRulesJSON(ctx, database.UpdateCollectionRulesJSONParams{
|
|
ID: pgtype.UUID{Bytes: collectionID, Valid: true},
|
|
AutoAssignRules: rulesJSON,
|
|
})
|
|
if err != nil {
|
|
return database.Collections{}, Rule{}, fmt.Errorf("failed to save rules: %w", err)
|
|
}
|
|
return updated, rule, nil
|
|
}
|
|
|
|
// SetRuleEnabled flips a single rule's enabled flag in place.
|
|
func (s *CollectionService) SetRuleEnabled(ctx context.Context, collectionID uuid.UUID, ruleID string, enabled bool) (database.Collections, error) {
|
|
collection, err := s.GetCollection(ctx, collectionID)
|
|
if err != nil {
|
|
return database.Collections{}, err
|
|
}
|
|
rules := s.ParseRules(collection.AutoAssignRules)
|
|
found := false
|
|
for i := range rules {
|
|
if rules[i].ID == ruleID {
|
|
rules[i].Enabled = &enabled
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
return database.Collections{}, fmt.Errorf("rule not found: %s", ruleID)
|
|
}
|
|
|
|
rulesJSON, marshalErr := json.Marshal(rules)
|
|
if marshalErr != nil {
|
|
return database.Collections{}, fmt.Errorf("failed to marshal rules: %w", marshalErr)
|
|
}
|
|
|
|
updated, err := s.db.UpdateCollectionRulesJSON(ctx, database.UpdateCollectionRulesJSONParams{
|
|
ID: pgtype.UUID{Bytes: collectionID, Valid: true},
|
|
AutoAssignRules: rulesJSON,
|
|
})
|
|
if err != nil {
|
|
return database.Collections{}, fmt.Errorf("failed to save rules: %w", err)
|
|
}
|
|
return updated, nil
|
|
}
|
|
|
|
// RemoveRule deletes a single rule from the ruleset. Removing the LAST
|
|
// rule converts the collection back to static (type is derived).
|
|
func (s *CollectionService) RemoveRule(ctx context.Context, collectionID uuid.UUID, ruleID string) (database.Collections, error) {
|
|
collection, err := s.GetCollection(ctx, collectionID)
|
|
if err != nil {
|
|
return database.Collections{}, err
|
|
}
|
|
rules := s.ParseRules(collection.AutoAssignRules)
|
|
kept := make([]Rule, 0, len(rules))
|
|
found := false
|
|
for _, rule := range rules {
|
|
if rule.ID == ruleID {
|
|
found = true
|
|
continue
|
|
}
|
|
kept = append(kept, rule)
|
|
}
|
|
if !found {
|
|
return database.Collections{}, fmt.Errorf("rule not found: %s", ruleID)
|
|
}
|
|
|
|
rulesJSON, marshalErr := json.Marshal(kept)
|
|
if marshalErr != nil {
|
|
return database.Collections{}, fmt.Errorf("failed to marshal rules: %w", marshalErr)
|
|
}
|
|
|
|
updated, err := s.db.UpdateCollectionRulesJSON(ctx, database.UpdateCollectionRulesJSONParams{
|
|
ID: pgtype.UUID{Bytes: collectionID, Valid: true},
|
|
AutoAssignRules: rulesJSON,
|
|
})
|
|
if err != nil {
|
|
return database.Collections{}, fmt.Errorf("failed to save rules: %w", err)
|
|
}
|
|
return updated, nil
|
|
}
|
|
|
|
// SmartEvalOptions scopes a smart collection's live evaluation.
|
|
type SmartEvalOptions struct {
|
|
// LibraryID, when valid, restricts matching to one library (the
|
|
// detail page's library switcher).
|
|
LibraryID pgtype.UUID
|
|
// 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
|
|
}
|
|
|
|
// IsCollectionSmart is the ONE smart detection: a collection is smart
|
|
// when it carries the unified filter tree (item 3.2) or a legacy rules
|
|
// array (item 2's shape). Every surface routes through this — rules
|
|
// presence alone misses tree collections.
|
|
func IsCollectionSmart(coll database.Collections) bool {
|
|
if len(coll.FilterTree) > 0 {
|
|
return true
|
|
}
|
|
var rules []Rule
|
|
if err := json.Unmarshal(coll.AutoAssignRules, &rules); err != nil {
|
|
return false
|
|
}
|
|
return len(rules) > 0
|
|
}
|
|
|
|
// EvaluateSmartMembership is THE single smart evaluator (item 3.2):
|
|
// tree-bearing collections evaluate through the unified filter engine;
|
|
// legacy rule arrays keep per-item evaluation until rewritten. One
|
|
// call site surface, two storage epochs.
|
|
func (s *CollectionService) EvaluateSmartMembership(ctx context.Context, coll database.Collections, opts SmartEvalOptions) ([]database.MediaItems, error) {
|
|
if len(coll.FilterTree) > 0 {
|
|
return s.evaluateTreeMembership(ctx, coll, opts)
|
|
}
|
|
return s.EvaluateSmartCollection(ctx, s.ParseRules(coll.AutoAssignRules), opts)
|
|
}
|
|
|
|
// RulesToFilterTree converts a legacy rules array + its match mode
|
|
// into a qf tree so a collection upgrades to the unified engine at its
|
|
// first edit (item 3.2): zero-rewrite back-compat at read, one-way
|
|
// upgrade at write. Episode rules map mechanically (feed→in-list,
|
|
// played/downloaded→booleans, published_within→published_date
|
|
// within_days); legacy operators without an engine equivalent
|
|
// (starts_with/ends_with) fall back to contains — documented, and the
|
|
// operator labels stay in the rules docs.
|
|
func RulesToFilterTree(rules []Rule, matchMode string) (*FilterTree, error) {
|
|
children := make([]FilterChild, 0, len(rules))
|
|
scope := ""
|
|
for _, r := range rules {
|
|
child, err := ruleToFilterChild(r)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if episodeOnlyFields[r.Field] {
|
|
scope = ScopeEpisodes
|
|
}
|
|
children = append(children, child)
|
|
}
|
|
if len(children) == 0 {
|
|
return nil, fmt.Errorf("no rules to convert")
|
|
}
|
|
mode := RuleMatchAny
|
|
if matchMode == RuleMatchAll {
|
|
mode = RuleMatchAll
|
|
}
|
|
return &FilterTree{Scope: scope, Mode: mode, Children: children}, nil
|
|
}
|
|
|
|
func ruleToFilterChild(r Rule) (FilterChild, error) {
|
|
child := FilterChild{Field: r.Field}
|
|
switch r.Field {
|
|
case "feed":
|
|
// The rules format stores comma-separated feed IDs in one value.
|
|
child.Op = OpIn
|
|
child.Values = splitRuleValues(r.Value)
|
|
return child, nil
|
|
case "downloaded":
|
|
child.Op = isTrueFalse(r.Value == "true")
|
|
return child, nil
|
|
case "played":
|
|
child.Op = isTrueFalse(r.Value == "true")
|
|
return child, nil
|
|
case "published_within":
|
|
child.Field = "published_date"
|
|
child.Op = OpWithinDays
|
|
child.Values = []string{r.Value}
|
|
return child, nil
|
|
}
|
|
// Registry fields: legacy operators → engine operators.
|
|
switch r.Operator {
|
|
case "equals":
|
|
child.Op = OpIs
|
|
case "not_equals":
|
|
child.Op = OpIsNot
|
|
case "contains":
|
|
child.Op = OpContains
|
|
case "not_contains":
|
|
child.Op = OpNotContains
|
|
case "greater_than":
|
|
child.Op = OpAtLeast
|
|
case "less_than":
|
|
child.Op = OpAtMost
|
|
case "in":
|
|
child.Op = OpIn
|
|
child.Values = splitRuleValues(r.Value)
|
|
case "starts_with", "ends_with":
|
|
// No engine equivalent — contains is the honest nearest.
|
|
child.Op = OpContains
|
|
default:
|
|
return FilterChild{}, fmt.Errorf("legacy rule operator %q has no filter-engine equivalent", r.Operator)
|
|
}
|
|
child.Values = []string{r.Value}
|
|
return child, nil
|
|
}
|
|
|
|
func isTrueFalse(b bool) string {
|
|
if b {
|
|
return OpIsTrue
|
|
}
|
|
return OpIsFalse
|
|
}
|
|
|
|
func splitRuleValues(v string) []string {
|
|
parts := strings.Split(v, ",")
|
|
out := make([]string, 0, len(parts))
|
|
for _, p := range parts {
|
|
p = strings.TrimSpace(p)
|
|
if p != "" {
|
|
out = append(out, p)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (s *CollectionService) evaluateTreeMembership(ctx context.Context, coll database.Collections, opts SmartEvalOptions) ([]database.MediaItems, error) {
|
|
tree, err := ParseFilterTree(string(coll.FilterTree))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("collection %s has an invalid filter tree: %v", coll.ID, err)
|
|
}
|
|
if s.search == nil {
|
|
return nil, errors.New("tree collections require the search service (constructor wiring)")
|
|
}
|
|
return s.search.ComposedLiveMedia(ctx, opts.ViewerUserID, opts.LibraryID, opts.VisibleIDs, tree)
|
|
}
|
|
|
|
// EvaluateSmartCollection resolves a smart collection's membership LIVE:
|
|
// every visible live book matched by at least one enabled rule is in.
|
|
// No stored links are consulted — "truly dynamic," always current.
|
|
func (s *CollectionService) EvaluateSmartCollection(ctx context.Context, rules []Rule, opts SmartEvalOptions) ([]database.MediaItems, error) {
|
|
enabled := s.EnabledRules(rules)
|
|
if len(enabled) == 0 {
|
|
return []database.MediaItems{}, nil
|
|
}
|
|
|
|
var visibleArg []pgtype.UUID
|
|
if len(opts.VisibleIDs) > 0 {
|
|
visibleArg = opts.VisibleIDs
|
|
}
|
|
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 _, row := range items {
|
|
if s.mediaItemMatches(mode, row.MediaItems, row.ViewerPercentage, enabled) {
|
|
matches = append(matches, row.MediaItems)
|
|
}
|
|
}
|
|
return matches, nil
|
|
}
|
|
|
|
// 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, viewerPercentage, rule) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// 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 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
|
|
}
|
|
case "series":
|
|
if item.Series.Valid {
|
|
fieldValue = item.Series.String
|
|
}
|
|
case "author":
|
|
if item.Author.Valid {
|
|
fieldValue = item.Author.String
|
|
}
|
|
case "language":
|
|
if item.Language.Valid {
|
|
fieldValue = item.Language.String
|
|
}
|
|
case "publisher":
|
|
if item.Publisher.Valid {
|
|
fieldValue = item.Publisher.String
|
|
}
|
|
case "copyright_year":
|
|
if item.CopyrightYear.Valid {
|
|
fieldValue = fmt.Sprintf("%d", item.CopyrightYear.Int32)
|
|
}
|
|
case "tags":
|
|
if len(item.Tags) > 0 {
|
|
fieldValue = strings.Join(item.Tags, ", ")
|
|
}
|
|
}
|
|
|
|
return s.evaluateRule(fieldValue, operator, value)
|
|
}
|
|
|
|
// 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)
|
|
sortedRules := make([]Rule, len(rules))
|
|
copy(sortedRules, rules)
|
|
for i := range len(sortedRules) {
|
|
for j := i + 1; j < len(sortedRules); j++ {
|
|
if sortedRules[i].Priority < sortedRules[j].Priority {
|
|
sortedRules[i], sortedRules[j] = sortedRules[j], sortedRules[i]
|
|
}
|
|
}
|
|
}
|
|
|
|
for _, rule := range sortedRules {
|
|
// Disabled rules never match (kept for context in the UI only).
|
|
if rule.Enabled != nil && !*rule.Enabled {
|
|
evaluations = append(evaluations, RuleEvaluation{RuleID: rule.ID, Matches: false})
|
|
continue
|
|
}
|
|
eval := RuleEvaluation{
|
|
RuleID: rule.ID,
|
|
Matches: s.FieldMatches(mediaItem, viewerPercentage, rule.Field, rule.Operator, rule.Value),
|
|
}
|
|
|
|
// Calculate confidence based on rule type and priority
|
|
if eval.Matches {
|
|
baseConfidence := 0.7
|
|
switch rule.Field {
|
|
case "genre", "series":
|
|
baseConfidence = 0.9
|
|
case "author", "publisher":
|
|
baseConfidence = 0.8
|
|
}
|
|
// Add priority boost (0.01 per priority point, max 0.1)
|
|
priorityBoost := float64(rule.Priority) * 0.01
|
|
if priorityBoost > 0.1 {
|
|
priorityBoost = 0.1
|
|
}
|
|
eval.Confidence = baseConfidence + priorityBoost
|
|
if eval.Confidence > 1.0 {
|
|
eval.Confidence = 1.0
|
|
}
|
|
}
|
|
|
|
evaluations = append(evaluations, eval)
|
|
}
|
|
|
|
return evaluations
|
|
}
|
|
|
|
// evaluateRule evaluates a single rule
|
|
func (s *CollectionService) evaluateRule(fieldValue, operator, ruleValue string) bool {
|
|
switch operator {
|
|
case "equals":
|
|
return containsIgnoreCase(fieldValue, ruleValue) && len(fieldValue) == len(ruleValue)
|
|
case "contains":
|
|
return containsIgnoreCase(fieldValue, ruleValue)
|
|
case "starts_with":
|
|
return hasPrefixIgnoreCase(fieldValue, ruleValue)
|
|
case "ends_with":
|
|
return hasSuffixIgnoreCase(fieldValue, ruleValue)
|
|
case "greater_than":
|
|
return compareNumbers(fieldValue, ruleValue, ">")
|
|
case "less_than":
|
|
return compareNumbers(fieldValue, ruleValue, "<")
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// containsIgnoreCase checks if a string contains another (case-insensitive)
|
|
func containsIgnoreCase(s, substr string) bool {
|
|
s = toLower(s)
|
|
substr = toLower(substr)
|
|
return contains(s, substr)
|
|
}
|
|
|
|
// hasPrefixIgnoreCase checks if a string starts with a prefix (case-insensitive)
|
|
func hasPrefixIgnoreCase(s, prefix string) bool {
|
|
s = toLower(s)
|
|
prefix = toLower(prefix)
|
|
return hasPrefix(s, prefix)
|
|
}
|
|
|
|
// hasSuffixIgnoreCase checks if a string ends with a suffix (case-insensitive)
|
|
func hasSuffixIgnoreCase(s, suffix string) bool {
|
|
s = toLower(s)
|
|
suffix = toLower(suffix)
|
|
return hasSuffix(s, suffix)
|
|
}
|
|
|
|
// compareNumbers compares two numeric strings
|
|
func compareNumbers(a, b, op string) bool {
|
|
aFloat, errA := parseFloat(a)
|
|
bFloat, errB := parseFloat(b)
|
|
|
|
if errA != nil || errB != nil {
|
|
return false
|
|
}
|
|
|
|
switch op {
|
|
case ">":
|
|
return aFloat > bFloat
|
|
case "<":
|
|
return aFloat < bFloat
|
|
case ">=":
|
|
return aFloat >= bFloat
|
|
case "<=":
|
|
return aFloat <= bFloat
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// CreateDeviceShelfMapping creates a shelf mapping for a device
|
|
func (s *CollectionService) CreateDeviceShelfMapping(ctx context.Context, collectionID, deviceID uuid.UUID, deviceShelfName, syncDirection string) (database.DeviceShelfMappings, error) {
|
|
mapping, err := s.db.CreateDeviceShelfMapping(ctx, database.CreateDeviceShelfMappingParams{
|
|
CollectionID: pgtype.UUID{Bytes: collectionID, Valid: true},
|
|
DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true},
|
|
DeviceShelfName: pgtype.Text{String: deviceShelfName, Valid: deviceShelfName != ""},
|
|
SyncDirection: pgtype.Text{String: syncDirection, Valid: syncDirection != ""},
|
|
})
|
|
|
|
if err != nil {
|
|
return database.DeviceShelfMappings{}, fmt.Errorf("failed to create shelf mapping: %v", err)
|
|
}
|
|
|
|
return mapping, nil
|
|
}
|
|
|
|
// GetDeviceShelfMappings gets all shelf mappings for a device
|
|
func (s *CollectionService) GetDeviceShelfMappings(ctx context.Context, deviceID uuid.UUID) ([]database.GetDeviceShelfMappingsRow, error) {
|
|
mappings, err := s.db.GetDeviceShelfMappings(ctx, pgtype.UUID{Bytes: deviceID, Valid: true})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get shelf mappings: %v", err)
|
|
}
|
|
|
|
return mappings, nil
|
|
}
|
|
|
|
// UpdateDeviceShelfMapping updates a shelf mapping
|
|
func (s *CollectionService) UpdateDeviceShelfMapping(ctx context.Context, mappingID uuid.UUID, deviceShelfName, syncDirection string) (database.DeviceShelfMappings, error) {
|
|
mapping, err := s.db.UpdateDeviceShelfMapping(ctx, database.UpdateDeviceShelfMappingParams{
|
|
ID: pgtype.UUID{Bytes: mappingID, Valid: true},
|
|
DeviceShelfName: pgtype.Text{String: deviceShelfName, Valid: deviceShelfName != ""},
|
|
SyncDirection: pgtype.Text{String: syncDirection, Valid: syncDirection != ""},
|
|
})
|
|
|
|
if err != nil {
|
|
return database.DeviceShelfMappings{}, fmt.Errorf("failed to update shelf mapping: %v", err)
|
|
}
|
|
|
|
return mapping, nil
|
|
}
|
|
|
|
// DeleteDeviceShelfMapping deletes a shelf mapping
|
|
func (s *CollectionService) DeleteDeviceShelfMapping(ctx context.Context, mappingID uuid.UUID) error {
|
|
err := s.db.DeleteDeviceShelfMapping(ctx, pgtype.UUID{Bytes: mappingID, Valid: true})
|
|
if err != nil {
|
|
return fmt.Errorf("failed to delete shelf mapping: %v", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func toLower(s string) string {
|
|
if len(s) == 0 {
|
|
return s
|
|
}
|
|
|
|
result := make([]byte, len(s))
|
|
for i := 0; i < len(s); i++ {
|
|
c := s[i]
|
|
if c >= 'A' && c <= 'Z' {
|
|
result[i] = c + 32
|
|
} else {
|
|
result[i] = c
|
|
}
|
|
}
|
|
return string(result)
|
|
}
|
|
|
|
func contains(s, substr string) bool {
|
|
return len(s) >= len(substr) && indexOf(s, substr) >= 0
|
|
}
|
|
|
|
func hasPrefix(s, prefix string) bool {
|
|
return len(s) >= len(prefix) && s[:len(prefix)] == prefix
|
|
}
|
|
|
|
func hasSuffix(s, suffix string) bool {
|
|
return len(s) >= len(suffix) && s[len(s)-len(suffix):] == suffix
|
|
}
|
|
|
|
func indexOf(s, substr string) int {
|
|
for i := 0; i <= len(s)-len(substr); i++ {
|
|
if s[i:i+len(substr)] == substr {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
func parseFloat(s string) (float64, error) {
|
|
var result float64
|
|
var sign float64 = 1
|
|
var divisor float64 = 1
|
|
var decimalPlaces = 0
|
|
var seenDecimal bool
|
|
|
|
// Skip leading whitespace
|
|
start := 0
|
|
for start < len(s) && (s[start] == ' ' || s[start] == '\t' || s[start] == '\n') {
|
|
start++
|
|
}
|
|
|
|
// Handle sign
|
|
if start < len(s) && s[start] == '-' {
|
|
sign = -1
|
|
start++
|
|
} else if start < len(s) && s[start] == '+' {
|
|
start++
|
|
}
|
|
|
|
// Parse digits
|
|
for i := start; i < len(s); i++ {
|
|
c := s[i]
|
|
if c >= '0' && c <= '9' {
|
|
result = result*10 + float64(c-'0')
|
|
if seenDecimal {
|
|
decimalPlaces++
|
|
divisor *= 10
|
|
}
|
|
} else if c == '.' && !seenDecimal {
|
|
seenDecimal = true
|
|
} else {
|
|
break
|
|
}
|
|
}
|
|
|
|
return sign * result / divisor, nil
|
|
}
|
|
|
|
// UserOwnsCollection gates playlist-ordering writes (A2c §3.2): the
|
|
// collection row's owner is the only user whose drag actions rewrite
|
|
// positions.
|
|
func (s *CollectionService) UserOwnsCollection(ctx context.Context, collectionID, userID uuid.UUID) (bool, error) {
|
|
collection, err := s.db.GetCollection(ctx, pgtype.UUID{Bytes: collectionID, Valid: true})
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return uuid.UUID(collection.UserID.Bytes) == userID, nil
|
|
}
|
|
|
|
// ReorderItems applies a full playlist order (A2c §3.2): the payload is the
|
|
// complete ordered id list; positions become 1..n in payload order. Ids
|
|
// not in the payload keep their relative order after — the SQL only
|
|
// touches the given ids, and NULL positions sort last. Never deletes.
|
|
func (s *CollectionService) ReorderItems(ctx context.Context, collectionID uuid.UUID, orderedIDs []uuid.UUID) error {
|
|
if len(orderedIDs) == 0 {
|
|
return nil
|
|
}
|
|
ids := make([]pgtype.UUID, 0, len(orderedIDs))
|
|
for _, id := range orderedIDs {
|
|
ids = append(ids, pgtype.UUID{Bytes: id, Valid: true})
|
|
}
|
|
if err := s.db.ReorderCollectionItems(ctx, database.ReorderCollectionItemsParams{
|
|
CollectionID: pgtype.UUID{Bytes: collectionID, Valid: true},
|
|
MediaItemIds: ids,
|
|
}); err != nil {
|
|
return fmt.Errorf("failed to reorder collection items: %v", err)
|
|
}
|
|
return nil
|
|
}
|