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: %w", 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 }