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
@@ -35,6 +35,7 @@ type Collections struct {
|
||||
Color pgtype.Text `db:"color" json:"color"`
|
||||
Icon pgtype.Text `db:"icon" json:"icon"`
|
||||
AutoAssignRules []byte `db:"auto_assign_rules" json:"auto_assign_rules"`
|
||||
RuleMatchMode string `db:"rule_match_mode" json:"rule_match_mode"`
|
||||
ViewSettings []byte `db:"view_settings" json:"view_settings"`
|
||||
ShowOnDashboard pgtype.Bool `db:"show_on_dashboard" json:"show_on_dashboard"`
|
||||
QueryType pgtype.Text `db:"query_type" json:"query_type"`
|
||||
|
||||
@@ -603,8 +603,10 @@ type Querier interface {
|
||||
// membership comes from the candidate book set instead of collection_items.
|
||||
// The set is scoped to the requesting user's visible libraries (family
|
||||
// lens enforced at the source) and live items only — the same filters the
|
||||
// static collection paths apply.
|
||||
GetVisibleLiveMediaForRules(ctx context.Context, arg GetVisibleLiveMediaForRulesParams) ([]MediaItems, error)
|
||||
// static collection paths apply. The viewer's progress rides along (plan
|
||||
// item 2.4): the played/unplayed episode rule evaluates per-user, exactly
|
||||
// like the hide_finished predicate (percentage >= 0.999 = finished).
|
||||
GetVisibleLiveMediaForRules(ctx context.Context, arg GetVisibleLiveMediaForRulesParams) ([]GetVisibleLiveMediaForRulesRow, error)
|
||||
HasRecentConflictResolution(ctx context.Context, arg HasRecentConflictResolutionParams) (bool, error)
|
||||
// Soft-dismiss one row (ownership is checked by the caller against the
|
||||
// device's user_id before calling this).
|
||||
@@ -826,6 +828,9 @@ type Querier interface {
|
||||
UnignoreUnlinkedBook(ctx context.Context, id pgtype.UUID) error
|
||||
// Update collection
|
||||
UpdateCollection(ctx context.Context, arg UpdateCollectionParams) (Collections, error)
|
||||
// Smart-collection match semantics (plan item 2.4): flip any|all on its
|
||||
// own channel — the rules page toggle writes exactly this column.
|
||||
UpdateCollectionMatchMode(ctx context.Context, arg UpdateCollectionMatchModeParams) error
|
||||
// Replace ONLY the auto_assign_rules document (rule CRUD: read-modify-write
|
||||
// of the JSONB without touching metadata stale clients did not send).
|
||||
UpdateCollectionRulesJSON(ctx context.Context, arg UpdateCollectionRulesJSONParams) (Collections, error)
|
||||
|
||||
+157
-113
@@ -673,7 +673,7 @@ const CreateCollection = `-- name: CreateCollection :one
|
||||
|
||||
INSERT INTO collections (user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING id, user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at
|
||||
RETURNING id, user_id, name, description, color, icon, auto_assign_rules, rule_match_mode, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at
|
||||
`
|
||||
|
||||
type CreateCollectionParams struct {
|
||||
@@ -709,6 +709,7 @@ func (q *Queries) CreateCollection(ctx context.Context, arg CreateCollectionPara
|
||||
&i.Color,
|
||||
&i.Icon,
|
||||
&i.AutoAssignRules,
|
||||
&i.RuleMatchMode,
|
||||
&i.ViewSettings,
|
||||
&i.ShowOnDashboard,
|
||||
&i.QueryType,
|
||||
@@ -2252,7 +2253,7 @@ func (q *Queries) CreateSyncQueueItem(ctx context.Context, arg CreateSyncQueueIt
|
||||
const CreateSystemCollection = `-- name: CreateSystemCollection :one
|
||||
INSERT INTO collections (user_id, name, description, icon, color, show_on_dashboard, query_type, priority, is_system_collection, auto_assign_rules)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, true, 'null'::jsonb)
|
||||
RETURNING id, user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at
|
||||
RETURNING id, user_id, name, description, color, icon, auto_assign_rules, rule_match_mode, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at
|
||||
`
|
||||
|
||||
type CreateSystemCollectionParams struct {
|
||||
@@ -2286,6 +2287,7 @@ func (q *Queries) CreateSystemCollection(ctx context.Context, arg CreateSystemCo
|
||||
&i.Color,
|
||||
&i.Icon,
|
||||
&i.AutoAssignRules,
|
||||
&i.RuleMatchMode,
|
||||
&i.ViewSettings,
|
||||
&i.ShowOnDashboard,
|
||||
&i.QueryType,
|
||||
@@ -4383,7 +4385,7 @@ func (q *Queries) GetBooksByTag(ctx context.Context, arg GetBooksByTagParams) ([
|
||||
}
|
||||
|
||||
const GetCollection = `-- name: GetCollection :one
|
||||
SELECT id, user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at FROM collections WHERE id = $1
|
||||
SELECT id, user_id, name, description, color, icon, auto_assign_rules, rule_match_mode, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at FROM collections WHERE id = $1
|
||||
`
|
||||
|
||||
// Get collection
|
||||
@@ -4398,6 +4400,7 @@ func (q *Queries) GetCollection(ctx context.Context, id pgtype.UUID) (Collection
|
||||
&i.Color,
|
||||
&i.Icon,
|
||||
&i.AutoAssignRules,
|
||||
&i.RuleMatchMode,
|
||||
&i.ViewSettings,
|
||||
&i.ShowOnDashboard,
|
||||
&i.QueryType,
|
||||
@@ -4410,7 +4413,7 @@ func (q *Queries) GetCollection(ctx context.Context, id pgtype.UUID) (Collection
|
||||
}
|
||||
|
||||
const GetCollectionByImportedSeries = `-- name: GetCollectionByImportedSeries :one
|
||||
SELECT id, user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at FROM collections
|
||||
SELECT id, user_id, name, description, color, icon, auto_assign_rules, rule_match_mode, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at FROM collections
|
||||
WHERE user_id = $1
|
||||
AND view_settings->>'imported_from_series' = $2::text
|
||||
AND deleted_at IS NULL
|
||||
@@ -4436,6 +4439,7 @@ func (q *Queries) GetCollectionByImportedSeries(ctx context.Context, arg GetColl
|
||||
&i.Color,
|
||||
&i.Icon,
|
||||
&i.AutoAssignRules,
|
||||
&i.RuleMatchMode,
|
||||
&i.ViewSettings,
|
||||
&i.ShowOnDashboard,
|
||||
&i.QueryType,
|
||||
@@ -4448,7 +4452,7 @@ func (q *Queries) GetCollectionByImportedSeries(ctx context.Context, arg GetColl
|
||||
}
|
||||
|
||||
const GetCollectionByUserAndName = `-- name: GetCollectionByUserAndName :one
|
||||
SELECT id, user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at FROM collections
|
||||
SELECT id, user_id, name, description, color, icon, auto_assign_rules, rule_match_mode, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at FROM collections
|
||||
WHERE user_id = $1 AND name = $2 AND deleted_at IS NULL
|
||||
LIMIT 1
|
||||
`
|
||||
@@ -4469,6 +4473,7 @@ func (q *Queries) GetCollectionByUserAndName(ctx context.Context, arg GetCollect
|
||||
&i.Color,
|
||||
&i.Icon,
|
||||
&i.AutoAssignRules,
|
||||
&i.RuleMatchMode,
|
||||
&i.ViewSettings,
|
||||
&i.ShowOnDashboard,
|
||||
&i.QueryType,
|
||||
@@ -4961,7 +4966,7 @@ func (q *Queries) GetCollectionUpNext(ctx context.Context, arg GetCollectionUpNe
|
||||
|
||||
const GetCollectionWithBookCount = `-- name: GetCollectionWithBookCount :one
|
||||
SELECT
|
||||
c.id, c.user_id, c.name, c.description, c.color, c.icon, c.auto_assign_rules, c.view_settings, c.show_on_dashboard, c.query_type, c.priority, c.is_system_collection, c.created_at, c.deleted_at,
|
||||
c.id, c.user_id, c.name, c.description, c.color, c.icon, c.auto_assign_rules, c.rule_match_mode, c.view_settings, c.show_on_dashboard, c.query_type, c.priority, c.is_system_collection, c.created_at, c.deleted_at,
|
||||
COUNT(ci.id) as book_count
|
||||
FROM collections c
|
||||
LEFT JOIN collection_items ci ON c.id = ci.collection_id
|
||||
@@ -4977,6 +4982,7 @@ type GetCollectionWithBookCountRow struct {
|
||||
Color pgtype.Text `db:"color" json:"color"`
|
||||
Icon pgtype.Text `db:"icon" json:"icon"`
|
||||
AutoAssignRules []byte `db:"auto_assign_rules" json:"auto_assign_rules"`
|
||||
RuleMatchMode string `db:"rule_match_mode" json:"rule_match_mode"`
|
||||
ViewSettings []byte `db:"view_settings" json:"view_settings"`
|
||||
ShowOnDashboard pgtype.Bool `db:"show_on_dashboard" json:"show_on_dashboard"`
|
||||
QueryType pgtype.Text `db:"query_type" json:"query_type"`
|
||||
@@ -4999,6 +5005,7 @@ func (q *Queries) GetCollectionWithBookCount(ctx context.Context, id pgtype.UUID
|
||||
&i.Color,
|
||||
&i.Icon,
|
||||
&i.AutoAssignRules,
|
||||
&i.RuleMatchMode,
|
||||
&i.ViewSettings,
|
||||
&i.ShowOnDashboard,
|
||||
&i.QueryType,
|
||||
@@ -5012,7 +5019,7 @@ func (q *Queries) GetCollectionWithBookCount(ctx context.Context, id pgtype.UUID
|
||||
}
|
||||
|
||||
const GetCollectionsByUser = `-- name: GetCollectionsByUser :many
|
||||
SELECT id, user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at FROM collections WHERE user_id = $1 AND deleted_at IS NULL ORDER BY created_at DESC
|
||||
SELECT id, user_id, name, description, color, icon, auto_assign_rules, rule_match_mode, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at FROM collections WHERE user_id = $1 AND deleted_at IS NULL ORDER BY created_at DESC
|
||||
`
|
||||
|
||||
// Get collections by user
|
||||
@@ -5033,6 +5040,7 @@ func (q *Queries) GetCollectionsByUser(ctx context.Context, userID pgtype.UUID)
|
||||
&i.Color,
|
||||
&i.Icon,
|
||||
&i.AutoAssignRules,
|
||||
&i.RuleMatchMode,
|
||||
&i.ViewSettings,
|
||||
&i.ShowOnDashboard,
|
||||
&i.QueryType,
|
||||
@@ -5052,7 +5060,7 @@ func (q *Queries) GetCollectionsByUser(ctx context.Context, userID pgtype.UUID)
|
||||
}
|
||||
|
||||
const GetCollectionsForBook = `-- name: GetCollectionsForBook :many
|
||||
SELECT c.id, c.user_id, c.name, c.description, c.color, c.icon, c.auto_assign_rules, c.view_settings, c.show_on_dashboard, c.query_type, c.priority, c.is_system_collection, c.created_at, c.deleted_at
|
||||
SELECT c.id, c.user_id, c.name, c.description, c.color, c.icon, c.auto_assign_rules, c.rule_match_mode, c.view_settings, c.show_on_dashboard, c.query_type, c.priority, c.is_system_collection, c.created_at, c.deleted_at
|
||||
FROM collections c
|
||||
JOIN collection_items ci ON c.id = ci.collection_id
|
||||
WHERE ci.media_item_id = $1
|
||||
@@ -5077,6 +5085,7 @@ func (q *Queries) GetCollectionsForBook(ctx context.Context, mediaItemID pgtype.
|
||||
&i.Color,
|
||||
&i.Icon,
|
||||
&i.AutoAssignRules,
|
||||
&i.RuleMatchMode,
|
||||
&i.ViewSettings,
|
||||
&i.ShowOnDashboard,
|
||||
&i.QueryType,
|
||||
@@ -10781,7 +10790,7 @@ func (q *Queries) GetSyncQueueStats(ctx context.Context, deviceID pgtype.UUID) (
|
||||
|
||||
const GetSystemCollectionByQueryType = `-- name: GetSystemCollectionByQueryType :one
|
||||
|
||||
SELECT id, user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at FROM collections
|
||||
SELECT id, user_id, name, description, color, icon, auto_assign_rules, rule_match_mode, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at FROM collections
|
||||
WHERE user_id = $1
|
||||
AND query_type = $2
|
||||
AND is_system_collection = true
|
||||
@@ -10808,6 +10817,7 @@ func (q *Queries) GetSystemCollectionByQueryType(ctx context.Context, arg GetSys
|
||||
&i.Color,
|
||||
&i.Icon,
|
||||
&i.AutoAssignRules,
|
||||
&i.RuleMatchMode,
|
||||
&i.ViewSettings,
|
||||
&i.ShowOnDashboard,
|
||||
&i.QueryType,
|
||||
@@ -10820,7 +10830,7 @@ func (q *Queries) GetSystemCollectionByQueryType(ctx context.Context, arg GetSys
|
||||
}
|
||||
|
||||
const GetSystemCollectionsForDashboard = `-- name: GetSystemCollectionsForDashboard :many
|
||||
SELECT id, user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at FROM collections
|
||||
SELECT id, user_id, name, description, color, icon, auto_assign_rules, rule_match_mode, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at FROM collections
|
||||
WHERE user_id = $1
|
||||
AND is_system_collection = true
|
||||
AND show_on_dashboard = true
|
||||
@@ -10846,6 +10856,7 @@ func (q *Queries) GetSystemCollectionsForDashboard(ctx context.Context, userID p
|
||||
&i.Color,
|
||||
&i.Icon,
|
||||
&i.AutoAssignRules,
|
||||
&i.RuleMatchMode,
|
||||
&i.ViewSettings,
|
||||
&i.ShowOnDashboard,
|
||||
&i.QueryType,
|
||||
@@ -11551,7 +11562,7 @@ func (q *Queries) GetUserByUsername(ctx context.Context, username string) (GetUs
|
||||
}
|
||||
|
||||
const GetUserCollectionsForDashboard = `-- name: GetUserCollectionsForDashboard :many
|
||||
SELECT c.id, c.user_id, c.name, c.description, c.color, c.icon, c.auto_assign_rules, c.view_settings, c.show_on_dashboard, c.query_type, c.priority, c.is_system_collection, c.created_at, c.deleted_at FROM collections c
|
||||
SELECT c.id, c.user_id, c.name, c.description, c.color, c.icon, c.auto_assign_rules, c.rule_match_mode, c.view_settings, c.show_on_dashboard, c.query_type, c.priority, c.is_system_collection, c.created_at, c.deleted_at FROM collections c
|
||||
WHERE c.user_id = $1
|
||||
AND c.is_system_collection = false
|
||||
AND c.deleted_at IS NULL
|
||||
@@ -11579,6 +11590,7 @@ func (q *Queries) GetUserCollectionsForDashboard(ctx context.Context, userID pgt
|
||||
&i.Color,
|
||||
&i.Icon,
|
||||
&i.AutoAssignRules,
|
||||
&i.RuleMatchMode,
|
||||
&i.ViewSettings,
|
||||
&i.ShowOnDashboard,
|
||||
&i.QueryType,
|
||||
@@ -12339,124 +12351,134 @@ func (q *Queries) GetVisibleLibraryMediaCounts(ctx context.Context, userID pgtyp
|
||||
}
|
||||
|
||||
const GetVisibleLiveMediaForRules = `-- name: GetVisibleLiveMediaForRules :many
|
||||
SELECT mi.id, mi.library_id, mi.title, mi.subtitle, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.narrators, mi.authors, mi.authors_search, mi.publishers, mi.publishers_search, mi.abridged, mi.explicit, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.audio_metadata, mi.tags_search, mi.contributors_search, mi.narrators_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, mi.source, mi.source_feed_id, mi.episode_guid, mi.feed_position, mi.season_number, mi.episode_number, mi.enclosure_url, mi.declared_duration_seconds, mi.transcript_url, mi.chapters_url, mi.hosts, mi.guests, mi.download_state, mi.download_error, mi.download_attempts, mi.download_queue_pos FROM media_items mi
|
||||
WHERE ($1::uuid IS NULL OR mi.library_id = $1::uuid)
|
||||
AND ($2::uuid[] IS NULL OR mi.library_id = ANY($2::uuid[]))
|
||||
SELECT mi.id, mi.library_id, mi.title, mi.subtitle, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.narrators, mi.authors, mi.authors_search, mi.publishers, mi.publishers_search, mi.abridged, mi.explicit, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.audio_metadata, mi.tags_search, mi.contributors_search, mi.narrators_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, mi.source, mi.source_feed_id, mi.episode_guid, mi.feed_position, mi.season_number, mi.episode_number, mi.enclosure_url, mi.declared_duration_seconds, mi.transcript_url, mi.chapters_url, mi.hosts, mi.guests, mi.download_state, mi.download_error, mi.download_attempts, mi.download_queue_pos, COALESCE(rp.percentage, 0) AS viewer_percentage FROM media_items mi
|
||||
LEFT JOIN reading_progress rp ON rp.media_item_id = mi.id AND rp.user_id = $1::uuid
|
||||
WHERE ($2::uuid IS NULL OR mi.library_id = $2::uuid)
|
||||
AND ($3::uuid[] IS NULL OR mi.library_id = ANY($3::uuid[]))
|
||||
AND mi.archived_at IS NULL
|
||||
AND mi.missing_scan_count = 0
|
||||
ORDER BY mi.title ASC
|
||||
`
|
||||
|
||||
type GetVisibleLiveMediaForRulesParams struct {
|
||||
ViewerUserID pgtype.UUID `db:"viewer_user_id" json:"viewer_user_id"`
|
||||
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
||||
VisibleLibraryIds []pgtype.UUID `db:"visible_library_ids" json:"visible_library_ids"`
|
||||
}
|
||||
|
||||
type GetVisibleLiveMediaForRulesRow struct {
|
||||
MediaItems MediaItems `db:"media_items" json:"media_items"`
|
||||
ViewerPercentage float64 `db:"viewer_percentage" json:"viewer_percentage"`
|
||||
}
|
||||
|
||||
// Smart ("dynamic") collections: rules are evaluated LIVE at render, so
|
||||
// membership comes from the candidate book set instead of collection_items.
|
||||
// The set is scoped to the requesting user's visible libraries (family
|
||||
// lens enforced at the source) and live items only — the same filters the
|
||||
// static collection paths apply.
|
||||
func (q *Queries) GetVisibleLiveMediaForRules(ctx context.Context, arg GetVisibleLiveMediaForRulesParams) ([]MediaItems, error) {
|
||||
rows, err := q.db.Query(ctx, GetVisibleLiveMediaForRules, arg.LibraryID, arg.VisibleLibraryIds)
|
||||
// static collection paths apply. The viewer's progress rides along (plan
|
||||
// item 2.4): the played/unplayed episode rule evaluates per-user, exactly
|
||||
// like the hide_finished predicate (percentage >= 0.999 = finished).
|
||||
func (q *Queries) GetVisibleLiveMediaForRules(ctx context.Context, arg GetVisibleLiveMediaForRulesParams) ([]GetVisibleLiveMediaForRulesRow, error) {
|
||||
rows, err := q.db.Query(ctx, GetVisibleLiveMediaForRules, arg.ViewerUserID, arg.LibraryID, arg.VisibleLibraryIds)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []MediaItems{}
|
||||
items := []GetVisibleLiveMediaForRulesRow{}
|
||||
for rows.Next() {
|
||||
var i MediaItems
|
||||
var i GetVisibleLiveMediaForRulesRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.LibraryID,
|
||||
&i.Title,
|
||||
&i.Subtitle,
|
||||
&i.Author,
|
||||
&i.Isbn,
|
||||
&i.Description,
|
||||
&i.FilePath,
|
||||
&i.FileSize,
|
||||
&i.MimeType,
|
||||
&i.CoverImagePath,
|
||||
&i.Series,
|
||||
&i.SeriesNumber,
|
||||
&i.Tags,
|
||||
&i.Narrators,
|
||||
&i.Authors,
|
||||
&i.AuthorsSearch,
|
||||
&i.Publishers,
|
||||
&i.PublishersSearch,
|
||||
&i.Abridged,
|
||||
&i.Explicit,
|
||||
&i.Asin,
|
||||
&i.DatePublished,
|
||||
&i.Publisher,
|
||||
&i.Contributors,
|
||||
&i.Language,
|
||||
&i.Edition,
|
||||
&i.PageCount,
|
||||
&i.Genre,
|
||||
&i.CopyrightYear,
|
||||
&i.GoodreadsID,
|
||||
&i.OpenlibraryID,
|
||||
&i.GoogleBooksID,
|
||||
&i.AddedByAdminID,
|
||||
&i.CreatedAt,
|
||||
&i.ImportedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.FormatGroup,
|
||||
&i.FormatMimetype,
|
||||
&i.IsReflowable,
|
||||
&i.HasFixedLayout,
|
||||
&i.TotalCharacters,
|
||||
&i.ChapterCount,
|
||||
&i.EntitlementID,
|
||||
&i.RevisionNumber,
|
||||
&i.KoboContentID,
|
||||
&i.KoboMetadata,
|
||||
&i.MangaType,
|
||||
&i.ReadingDirection,
|
||||
&i.SeriesCount,
|
||||
&i.Volume,
|
||||
&i.Imprint,
|
||||
&i.AgeRating,
|
||||
&i.WebUrl,
|
||||
&i.StoryArc,
|
||||
&i.IsBlackAndWhite,
|
||||
&i.MetadataNotes,
|
||||
&i.CommunityRating,
|
||||
&i.AlternateInfo,
|
||||
&i.ScanInformation,
|
||||
&i.Summary,
|
||||
&i.MetadataOverrides,
|
||||
&i.MissingScanCount,
|
||||
&i.ArchivedAt,
|
||||
&i.ChapterMetadata,
|
||||
&i.LibraryTypeName,
|
||||
&i.AudioMetadata,
|
||||
&i.TagsSearch,
|
||||
&i.ContributorsSearch,
|
||||
&i.NarratorsSearch,
|
||||
&i.FileSha256,
|
||||
&i.OpfIdentifier,
|
||||
&i.OpfUuid,
|
||||
&i.HashConfidence,
|
||||
&i.Source,
|
||||
&i.SourceFeedID,
|
||||
&i.EpisodeGuid,
|
||||
&i.FeedPosition,
|
||||
&i.SeasonNumber,
|
||||
&i.EpisodeNumber,
|
||||
&i.EnclosureUrl,
|
||||
&i.DeclaredDurationSeconds,
|
||||
&i.TranscriptUrl,
|
||||
&i.ChaptersUrl,
|
||||
&i.Hosts,
|
||||
&i.Guests,
|
||||
&i.DownloadState,
|
||||
&i.DownloadError,
|
||||
&i.DownloadAttempts,
|
||||
&i.DownloadQueuePos,
|
||||
&i.MediaItems.ID,
|
||||
&i.MediaItems.LibraryID,
|
||||
&i.MediaItems.Title,
|
||||
&i.MediaItems.Subtitle,
|
||||
&i.MediaItems.Author,
|
||||
&i.MediaItems.Isbn,
|
||||
&i.MediaItems.Description,
|
||||
&i.MediaItems.FilePath,
|
||||
&i.MediaItems.FileSize,
|
||||
&i.MediaItems.MimeType,
|
||||
&i.MediaItems.CoverImagePath,
|
||||
&i.MediaItems.Series,
|
||||
&i.MediaItems.SeriesNumber,
|
||||
&i.MediaItems.Tags,
|
||||
&i.MediaItems.Narrators,
|
||||
&i.MediaItems.Authors,
|
||||
&i.MediaItems.AuthorsSearch,
|
||||
&i.MediaItems.Publishers,
|
||||
&i.MediaItems.PublishersSearch,
|
||||
&i.MediaItems.Abridged,
|
||||
&i.MediaItems.Explicit,
|
||||
&i.MediaItems.Asin,
|
||||
&i.MediaItems.DatePublished,
|
||||
&i.MediaItems.Publisher,
|
||||
&i.MediaItems.Contributors,
|
||||
&i.MediaItems.Language,
|
||||
&i.MediaItems.Edition,
|
||||
&i.MediaItems.PageCount,
|
||||
&i.MediaItems.Genre,
|
||||
&i.MediaItems.CopyrightYear,
|
||||
&i.MediaItems.GoodreadsID,
|
||||
&i.MediaItems.OpenlibraryID,
|
||||
&i.MediaItems.GoogleBooksID,
|
||||
&i.MediaItems.AddedByAdminID,
|
||||
&i.MediaItems.CreatedAt,
|
||||
&i.MediaItems.ImportedAt,
|
||||
&i.MediaItems.UpdatedAt,
|
||||
&i.MediaItems.FormatGroup,
|
||||
&i.MediaItems.FormatMimetype,
|
||||
&i.MediaItems.IsReflowable,
|
||||
&i.MediaItems.HasFixedLayout,
|
||||
&i.MediaItems.TotalCharacters,
|
||||
&i.MediaItems.ChapterCount,
|
||||
&i.MediaItems.EntitlementID,
|
||||
&i.MediaItems.RevisionNumber,
|
||||
&i.MediaItems.KoboContentID,
|
||||
&i.MediaItems.KoboMetadata,
|
||||
&i.MediaItems.MangaType,
|
||||
&i.MediaItems.ReadingDirection,
|
||||
&i.MediaItems.SeriesCount,
|
||||
&i.MediaItems.Volume,
|
||||
&i.MediaItems.Imprint,
|
||||
&i.MediaItems.AgeRating,
|
||||
&i.MediaItems.WebUrl,
|
||||
&i.MediaItems.StoryArc,
|
||||
&i.MediaItems.IsBlackAndWhite,
|
||||
&i.MediaItems.MetadataNotes,
|
||||
&i.MediaItems.CommunityRating,
|
||||
&i.MediaItems.AlternateInfo,
|
||||
&i.MediaItems.ScanInformation,
|
||||
&i.MediaItems.Summary,
|
||||
&i.MediaItems.MetadataOverrides,
|
||||
&i.MediaItems.MissingScanCount,
|
||||
&i.MediaItems.ArchivedAt,
|
||||
&i.MediaItems.ChapterMetadata,
|
||||
&i.MediaItems.LibraryTypeName,
|
||||
&i.MediaItems.AudioMetadata,
|
||||
&i.MediaItems.TagsSearch,
|
||||
&i.MediaItems.ContributorsSearch,
|
||||
&i.MediaItems.NarratorsSearch,
|
||||
&i.MediaItems.FileSha256,
|
||||
&i.MediaItems.OpfIdentifier,
|
||||
&i.MediaItems.OpfUuid,
|
||||
&i.MediaItems.HashConfidence,
|
||||
&i.MediaItems.Source,
|
||||
&i.MediaItems.SourceFeedID,
|
||||
&i.MediaItems.EpisodeGuid,
|
||||
&i.MediaItems.FeedPosition,
|
||||
&i.MediaItems.SeasonNumber,
|
||||
&i.MediaItems.EpisodeNumber,
|
||||
&i.MediaItems.EnclosureUrl,
|
||||
&i.MediaItems.DeclaredDurationSeconds,
|
||||
&i.MediaItems.TranscriptUrl,
|
||||
&i.MediaItems.ChaptersUrl,
|
||||
&i.MediaItems.Hosts,
|
||||
&i.MediaItems.Guests,
|
||||
&i.MediaItems.DownloadState,
|
||||
&i.MediaItems.DownloadError,
|
||||
&i.MediaItems.DownloadAttempts,
|
||||
&i.MediaItems.DownloadQueuePos,
|
||||
&i.ViewerPercentage,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -16448,7 +16470,7 @@ SET name = $2,
|
||||
show_on_dashboard = true,
|
||||
deleted_at = NULL
|
||||
WHERE id = $1
|
||||
RETURNING id, user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at
|
||||
RETURNING id, user_id, name, description, color, icon, auto_assign_rules, rule_match_mode, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at
|
||||
`
|
||||
|
||||
type RestoreSystemCollectionRowParams struct {
|
||||
@@ -16480,6 +16502,7 @@ func (q *Queries) RestoreSystemCollectionRow(ctx context.Context, arg RestoreSys
|
||||
&i.Color,
|
||||
&i.Icon,
|
||||
&i.AutoAssignRules,
|
||||
&i.RuleMatchMode,
|
||||
&i.ViewSettings,
|
||||
&i.ShowOnDashboard,
|
||||
&i.QueryType,
|
||||
@@ -18193,9 +18216,10 @@ SET
|
||||
color = $4,
|
||||
icon = $5,
|
||||
auto_assign_rules = $6,
|
||||
view_settings = $7
|
||||
view_settings = $7,
|
||||
rule_match_mode = $8
|
||||
WHERE id = $1
|
||||
RETURNING id, user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at
|
||||
RETURNING id, user_id, name, description, color, icon, auto_assign_rules, rule_match_mode, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at
|
||||
`
|
||||
|
||||
type UpdateCollectionParams struct {
|
||||
@@ -18206,6 +18230,7 @@ type UpdateCollectionParams struct {
|
||||
Icon pgtype.Text `db:"icon" json:"icon"`
|
||||
AutoAssignRules []byte `db:"auto_assign_rules" json:"auto_assign_rules"`
|
||||
ViewSettings []byte `db:"view_settings" json:"view_settings"`
|
||||
RuleMatchMode string `db:"rule_match_mode" json:"rule_match_mode"`
|
||||
}
|
||||
|
||||
// Update collection
|
||||
@@ -18218,6 +18243,7 @@ func (q *Queries) UpdateCollection(ctx context.Context, arg UpdateCollectionPara
|
||||
arg.Icon,
|
||||
arg.AutoAssignRules,
|
||||
arg.ViewSettings,
|
||||
arg.RuleMatchMode,
|
||||
)
|
||||
var i Collections
|
||||
err := row.Scan(
|
||||
@@ -18228,6 +18254,7 @@ func (q *Queries) UpdateCollection(ctx context.Context, arg UpdateCollectionPara
|
||||
&i.Color,
|
||||
&i.Icon,
|
||||
&i.AutoAssignRules,
|
||||
&i.RuleMatchMode,
|
||||
&i.ViewSettings,
|
||||
&i.ShowOnDashboard,
|
||||
&i.QueryType,
|
||||
@@ -18239,8 +18266,24 @@ func (q *Queries) UpdateCollection(ctx context.Context, arg UpdateCollectionPara
|
||||
return i, err
|
||||
}
|
||||
|
||||
const UpdateCollectionMatchMode = `-- name: UpdateCollectionMatchMode :exec
|
||||
UPDATE collections SET rule_match_mode = $2 WHERE id = $1
|
||||
`
|
||||
|
||||
type UpdateCollectionMatchModeParams struct {
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
RuleMatchMode string `db:"rule_match_mode" json:"rule_match_mode"`
|
||||
}
|
||||
|
||||
// Smart-collection match semantics (plan item 2.4): flip any|all on its
|
||||
// own channel — the rules page toggle writes exactly this column.
|
||||
func (q *Queries) UpdateCollectionMatchMode(ctx context.Context, arg UpdateCollectionMatchModeParams) error {
|
||||
_, err := q.db.Exec(ctx, UpdateCollectionMatchMode, arg.ID, arg.RuleMatchMode)
|
||||
return err
|
||||
}
|
||||
|
||||
const UpdateCollectionRulesJSON = `-- name: UpdateCollectionRulesJSON :one
|
||||
UPDATE collections SET auto_assign_rules = $2 WHERE id = $1 RETURNING id, user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at
|
||||
UPDATE collections SET auto_assign_rules = $2 WHERE id = $1 RETURNING id, user_id, name, description, color, icon, auto_assign_rules, rule_match_mode, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at
|
||||
`
|
||||
|
||||
type UpdateCollectionRulesJSONParams struct {
|
||||
@@ -18261,6 +18304,7 @@ func (q *Queries) UpdateCollectionRulesJSON(ctx context.Context, arg UpdateColle
|
||||
&i.Color,
|
||||
&i.Icon,
|
||||
&i.AutoAssignRules,
|
||||
&i.RuleMatchMode,
|
||||
&i.ViewSettings,
|
||||
&i.ShowOnDashboard,
|
||||
&i.QueryType,
|
||||
|
||||
@@ -3498,6 +3498,11 @@ RETURNING *;
|
||||
-- name: UpdateCollectionViewSettings :exec
|
||||
UPDATE collections SET view_settings = $2, updated_at = NOW() WHERE id = $1;
|
||||
|
||||
-- Smart-collection match semantics (plan item 2.4): flip any|all on its
|
||||
-- own channel — the rules page toggle writes exactly this column.
|
||||
-- name: UpdateCollectionMatchMode :exec
|
||||
UPDATE collections SET rule_match_mode = $2 WHERE id = $1;
|
||||
|
||||
-- Get collection
|
||||
-- name: GetCollection :one
|
||||
SELECT * FROM collections WHERE id = $1;
|
||||
@@ -3525,7 +3530,8 @@ SET
|
||||
color = $4,
|
||||
icon = $5,
|
||||
auto_assign_rules = $6,
|
||||
view_settings = $7
|
||||
view_settings = $7,
|
||||
rule_match_mode = $8
|
||||
WHERE id = $1
|
||||
RETURNING *;
|
||||
|
||||
@@ -4144,9 +4150,12 @@ SELECT COUNT(*) FROM collection_items WHERE collection_id = $1;
|
||||
-- membership comes from the candidate book set instead of collection_items.
|
||||
-- The set is scoped to the requesting user's visible libraries (family
|
||||
-- lens enforced at the source) and live items only — the same filters the
|
||||
-- static collection paths apply.
|
||||
-- static collection paths apply. The viewer's progress rides along (plan
|
||||
-- item 2.4): the played/unplayed episode rule evaluates per-user, exactly
|
||||
-- like the hide_finished predicate (percentage >= 0.999 = finished).
|
||||
-- name: GetVisibleLiveMediaForRules :many
|
||||
SELECT mi.* FROM media_items mi
|
||||
SELECT sqlc.embed(mi), COALESCE(rp.percentage, 0) AS viewer_percentage FROM media_items mi
|
||||
LEFT JOIN reading_progress rp ON rp.media_item_id = mi.id AND rp.user_id = sqlc.narg('viewer_user_id')::uuid
|
||||
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||
AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR mi.library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))
|
||||
AND mi.archived_at IS NULL
|
||||
|
||||
@@ -47,6 +47,9 @@ type CreateCollectionRequest struct {
|
||||
|
||||
type UpdateCollectionRequest struct {
|
||||
Name string `form:"name" json:"name" validate:"required"`
|
||||
// Smart-collection match semantics (item 2.4): "any" (OR) | "all"
|
||||
// (AND). Empty/invalid keeps the stored value.
|
||||
RuleMatchMode string `form:"rule_match_mode" json:"rule_match_mode"`
|
||||
Description string `form:"description" json:"description"`
|
||||
Color string `form:"color" json:"color"`
|
||||
Icon string `form:"icon" json:"icon"`
|
||||
@@ -217,8 +220,10 @@ func (h *CollectionHandler) GetCollections(c *echo.Context) error {
|
||||
visibleIDs, visErr := h.userVisibleLibraryIDs(c)
|
||||
if visErr == nil {
|
||||
matches, evalErr := h.collectionService.EvaluateSmartCollection(c.Request().Context(), rules, services.SmartEvalOptions{
|
||||
LibraryID: libUUID,
|
||||
VisibleIDs: visibleIDs,
|
||||
LibraryID: libUUID,
|
||||
VisibleIDs: visibleIDs,
|
||||
MatchMode: col.RuleMatchMode,
|
||||
ViewerUserID: MustGetAuthenticatedUser(c).ID,
|
||||
})
|
||||
if evalErr == nil {
|
||||
bookCount = len(matches)
|
||||
@@ -295,8 +300,10 @@ func (h *CollectionHandler) GetCollection(c *echo.Context) error {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
matches, evalErr := h.collectionService.EvaluateSmartCollection(c.Request().Context(), rules, services.SmartEvalOptions{
|
||||
LibraryID: libUUID,
|
||||
VisibleIDs: visibleIDs,
|
||||
LibraryID: libUUID,
|
||||
VisibleIDs: visibleIDs,
|
||||
MatchMode: collection.RuleMatchMode,
|
||||
ViewerUserID: MustGetAuthenticatedUser(c).ID,
|
||||
})
|
||||
if evalErr != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": evalErr.Error()})
|
||||
@@ -411,6 +418,7 @@ func (h *CollectionHandler) GetCollection(c *echo.Context) error {
|
||||
"icon": utils.PgTextString(collection.Icon),
|
||||
"auto_assign_rules": json.RawMessage(collection.AutoAssignRules),
|
||||
"view_settings": viewSettings,
|
||||
"rule_match_mode": collection.RuleMatchMode,
|
||||
"books": bookList,
|
||||
"book_count": len(bookList),
|
||||
"is_smart": isSmart,
|
||||
@@ -459,6 +467,7 @@ func (h *CollectionHandler) UpdateCollection(c *echo.Context) error {
|
||||
req.Icon,
|
||||
req.AutoAssignRules,
|
||||
req.ViewSettings,
|
||||
req.RuleMatchMode,
|
||||
)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
@@ -974,6 +983,9 @@ func (h *CollectionHandler) RemoveRule(c *echo.Context) error {
|
||||
|
||||
type TestRulesRequest struct {
|
||||
Rules []map[string]interface{} `json:"rules" validate:"required"`
|
||||
// MatchMode previews the collection's rule_match_mode: "any" (OR,
|
||||
// default) or "all" (AND — the daily briefing). Empty = any.
|
||||
MatchMode string `json:"match_mode"`
|
||||
}
|
||||
|
||||
type BookMatch struct {
|
||||
@@ -1004,18 +1016,26 @@ func (h *CollectionHandler) TestRules(c *echo.Context) error {
|
||||
if len(visibleIDs) > 0 {
|
||||
visibleArg = visibleIDs
|
||||
}
|
||||
viewerID := pgtype.UUID{}
|
||||
if u, ok := c.Get("user").(database.Users); ok {
|
||||
viewerID = u.ID
|
||||
}
|
||||
mediaItems, err := h.db.GetVisibleLiveMediaForRules(c.Request().Context(), database.GetVisibleLiveMediaForRulesParams{
|
||||
LibraryID: pgtype.UUID{},
|
||||
VisibleLibraryIds: visibleArg,
|
||||
ViewerUserID: viewerID,
|
||||
})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to load media items"})
|
||||
}
|
||||
|
||||
matchAll := req.MatchMode == services.RuleMatchAll
|
||||
|
||||
var matches []BookMatch
|
||||
for _, item := range mediaItems {
|
||||
matchReason := h.checkRulesAgainstBook(item, req.Rules)
|
||||
for _, row := range mediaItems {
|
||||
matchReason := h.checkRulesAgainstBook(row.MediaItems, row.ViewerPercentage, req.Rules, matchAll)
|
||||
if matchReason != "" {
|
||||
item := row.MediaItems
|
||||
author := ""
|
||||
if item.Author.Valid {
|
||||
author = item.Author.String
|
||||
@@ -1037,13 +1057,34 @@ func (h *CollectionHandler) TestRules(c *echo.Context) error {
|
||||
})
|
||||
}
|
||||
|
||||
func (h *CollectionHandler) checkRulesAgainstBook(item database.MediaItems, rules []map[string]interface{}) string {
|
||||
// checkRulesAgainstBook evaluates the candidate against the ruleset via
|
||||
// the SERVICE evaluator (one vocabulary, one semantics — the handler
|
||||
// keeps only the match-reason display). matchAll=true (the collection's
|
||||
// "all" mode) requires every rule to hit.
|
||||
func (h *CollectionHandler) checkRulesAgainstBook(item database.MediaItems, viewerPercentage float64, rules []map[string]interface{}, matchAll bool) string {
|
||||
service := h.collectionService
|
||||
if matchAll {
|
||||
matched := 0
|
||||
for _, rule := range rules {
|
||||
field, _ := rule["field"].(string)
|
||||
operator, _ := rule["operator"].(string)
|
||||
value, _ := rule["value"].(string)
|
||||
if !service.FieldMatches(item, viewerPercentage, field, operator, value) {
|
||||
return ""
|
||||
}
|
||||
matched++
|
||||
}
|
||||
if matched > 0 {
|
||||
return fmt.Sprintf("Matched all %d rules", matched)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
for _, rule := range rules {
|
||||
field, _ := rule["field"].(string)
|
||||
operator, _ := rule["operator"].(string)
|
||||
value, _ := rule["value"].(string)
|
||||
|
||||
if h.evaluateRule(item, field, operator, value) {
|
||||
if service.FieldMatches(item, viewerPercentage, field, operator, value) {
|
||||
return fmt.Sprintf("Matched rule: %s %s %s", field, operator, value)
|
||||
}
|
||||
}
|
||||
@@ -1268,9 +1309,12 @@ func (h *CollectionHandler) PreviewCollection(c *echo.Context) error {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to load library items"})
|
||||
}
|
||||
|
||||
// Book-scope preview (the rules page remains books-first until the
|
||||
// filter-unification pass); no viewer progress loaded here, so
|
||||
// played/unplayed rules preview at the no-progress default.
|
||||
var matchedItems []database.MediaItems
|
||||
for _, item := range allItems {
|
||||
evaluations := h.collectionService.EvaluateRules(item, req.Rules)
|
||||
evaluations := h.collectionService.EvaluateRules(item, 0, req.Rules)
|
||||
for _, eval := range evaluations {
|
||||
if eval.Matches {
|
||||
matchedItems = append(matchedItems, item)
|
||||
@@ -1320,6 +1364,43 @@ func (h *CollectionHandler) PreviewCollection(c *echo.Context) error {
|
||||
// (A2c §3.2). Owner-only; the payload is the COMPLETE ordered id list —
|
||||
// ids omitted keep their relative order appended after (the service never
|
||||
// deletes membership).
|
||||
// SetMatchMode handles PUT /api/collections/:id/match-mode (plan item
|
||||
// 2.4): flips the smart ruleset's semantics any|all on its own channel
|
||||
// — the rules page toggle writes exactly this column, nothing else.
|
||||
func (h *CollectionHandler) SetMatchMode(c *echo.Context) error {
|
||||
collectionID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"})
|
||||
}
|
||||
user, ok := c.Get("user").(database.Users)
|
||||
if !ok {
|
||||
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Mode string `json:"mode" form:"mode" validate:"required,oneof=any all"`
|
||||
}
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "mode must be any or all"})
|
||||
}
|
||||
if err := c.Validate(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
owns, err := h.collectionService.UserOwnsCollection(c.Request().Context(), collectionID, uuid.UUID(user.ID.Bytes))
|
||||
if err != nil || !owns {
|
||||
return c.JSON(http.StatusForbidden, map[string]string{"error": "only the collection owner can change it"})
|
||||
}
|
||||
|
||||
if err := h.db.UpdateCollectionMatchMode(c.Request().Context(), database.UpdateCollectionMatchModeParams{
|
||||
ID: pgtype.UUID{Bytes: collectionID, Valid: true},
|
||||
RuleMatchMode: req.Mode,
|
||||
}); err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]string{"mode": req.Mode})
|
||||
}
|
||||
|
||||
func (h *CollectionHandler) ReorderItems(c *echo.Context) error {
|
||||
collectionID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
|
||||
@@ -17,6 +17,7 @@ func registerCollectionsRoutes(cfg *Config) {
|
||||
collections.GET("/:id/books", cfg.CollectionHandler.GetBookCollections)
|
||||
collections.POST("/:id/books", cfg.CollectionHandler.AddBooks)
|
||||
collections.PUT("/:id/items/order", cfg.CollectionHandler.ReorderItems)
|
||||
collections.PUT("/:id/match-mode", cfg.CollectionHandler.SetMatchMode)
|
||||
collections.DELETE("/:id/books/:bookId", cfg.CollectionHandler.RemoveBook)
|
||||
collections.POST("/:id/books/bulk-remove", cfg.CollectionHandler.BulkRemoveBooks)
|
||||
collections.POST("/bulk-add-books", cfg.CollectionHandler.HandleBulkAddBooks)
|
||||
|
||||
@@ -567,8 +567,13 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
if isSmart {
|
||||
matches, evalErr := colService.EvaluateSmartCollection(c.Request().Context(),
|
||||
colService.ParseRules(col.AutoAssignRules), services.SmartEvalOptions{
|
||||
LibraryID: libScope,
|
||||
VisibleIDs: visibleIDs,
|
||||
LibraryID: libScope,
|
||||
VisibleIDs: visibleIDs,
|
||||
MatchMode: col.RuleMatchMode,
|
||||
ViewerUserID: func() pgtype.UUID {
|
||||
uid, _ := uuid.Parse(user.ID)
|
||||
return uuidToPGType(uid)
|
||||
}(),
|
||||
})
|
||||
if evalErr == nil {
|
||||
bookCount = len(matches)
|
||||
|
||||
@@ -278,6 +278,7 @@ func collectionDataFromRow(collection database.Collections, collectionID string,
|
||||
Icon: collection.Icon.String,
|
||||
IsSystem: collection.IsSystemCollection.Bool,
|
||||
IsSmart: isSmart,
|
||||
MatchMode: collection.RuleMatchMode,
|
||||
BookCount: bookCount,
|
||||
}
|
||||
}
|
||||
@@ -293,11 +294,15 @@ func (cfg *Config) collectionBooksForScope(c *echo.Context, userUUID uuid.UUID,
|
||||
|
||||
if isSmart {
|
||||
// Smart (dynamic) collection: membership is evaluated live —
|
||||
// visible libraries, live items only, any enabled rule matches.
|
||||
// visible libraries, live items only, rules applied under the
|
||||
// collection's match mode (item 2.4: any|all), with the
|
||||
// viewer's progress feeding played/unplayed rules.
|
||||
matches, evalErr := colService.EvaluateSmartCollection(c.Request().Context(),
|
||||
colService.ParseRules(collection.AutoAssignRules), services.SmartEvalOptions{
|
||||
LibraryID: libScope,
|
||||
VisibleIDs: visibleIDs,
|
||||
LibraryID: libScope,
|
||||
VisibleIDs: visibleIDs,
|
||||
MatchMode: collection.RuleMatchMode,
|
||||
ViewerUserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||
})
|
||||
if evalErr != nil {
|
||||
return nil, isSmart, "books_load_error"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -24,7 +24,7 @@ func TestEvaluateRules_Equality(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
evaluations := service.EvaluateRules(mediaItem, rules)
|
||||
evaluations := service.EvaluateRules(mediaItem, 0, rules)
|
||||
|
||||
if len(evaluations) != 1 {
|
||||
t.Fatalf("expected 1 evaluation, got %d", len(evaluations))
|
||||
@@ -56,7 +56,7 @@ func TestEvaluateRules_NoMatch(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
evaluations := service.EvaluateRules(mediaItem, rules)
|
||||
evaluations := service.EvaluateRules(mediaItem, 0, rules)
|
||||
|
||||
if len(evaluations) != 1 {
|
||||
t.Fatalf("expected 1 evaluation, got %d", len(evaluations))
|
||||
@@ -88,7 +88,7 @@ func TestEvaluateRules_Contains(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
evaluations := service.EvaluateRules(mediaItem, rules)
|
||||
evaluations := service.EvaluateRules(mediaItem, 0, rules)
|
||||
|
||||
if len(evaluations) != 1 {
|
||||
t.Fatalf("expected 1 evaluation, got %d", len(evaluations))
|
||||
@@ -128,7 +128,7 @@ func TestEvaluateRules_PriorityOrder(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
evaluations := service.EvaluateRules(mediaItem, rules)
|
||||
evaluations := service.EvaluateRules(mediaItem, 0, rules)
|
||||
|
||||
if len(evaluations) != 2 {
|
||||
t.Fatalf("expected 2 evaluations, got %d", len(evaluations))
|
||||
@@ -164,7 +164,7 @@ func TestEvaluateRules_CopyrightYear(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
evaluations := service.EvaluateRules(mediaItem, rules)
|
||||
evaluations := service.EvaluateRules(mediaItem, 0, rules)
|
||||
|
||||
if len(evaluations) != 1 {
|
||||
t.Fatalf("expected 1 evaluation, got %d", len(evaluations))
|
||||
@@ -190,7 +190,7 @@ func TestEvaluateRules_MissingField(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
evaluations := service.EvaluateRules(mediaItem, rules)
|
||||
evaluations := service.EvaluateRules(mediaItem, 0, rules)
|
||||
|
||||
if len(evaluations) != 1 {
|
||||
t.Fatalf("expected 1 evaluation, got %d", len(evaluations))
|
||||
@@ -218,7 +218,7 @@ func TestEvaluateRules_CaseInsensitive(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
evaluations := service.EvaluateRules(mediaItem, rules)
|
||||
evaluations := service.EvaluateRules(mediaItem, 0, rules)
|
||||
|
||||
if len(evaluations) != 1 {
|
||||
t.Fatalf("expected 1 evaluation, got %d", len(evaluations))
|
||||
@@ -253,7 +253,7 @@ func TestEvaluateRules_PriorityBoost(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
evaluations := service.EvaluateRules(mediaItem, rules)
|
||||
evaluations := service.EvaluateRules(mediaItem, 0, rules)
|
||||
|
||||
if len(evaluations) != 2 {
|
||||
t.Fatalf("expected 2 evaluations, got %d", len(evaluations))
|
||||
|
||||
@@ -318,7 +318,10 @@ func (s *DashboardService) getUserCollectionItems(ctx context.Context, coll data
|
||||
continue
|
||||
}
|
||||
|
||||
evaluations := s.collectionService.EvaluateRules(item, rules)
|
||||
// Legacy dashboard auto-assign path: no viewer progress
|
||||
// loaded here (the dashboard's own hide_finished handles
|
||||
// queue semantics) — played rules evaluate unplayed, honestly.
|
||||
evaluations := s.collectionService.EvaluateRules(item, 0, rules)
|
||||
for _, eval := range evaluations {
|
||||
if eval.Matches {
|
||||
autoItems = append(autoItems, item)
|
||||
|
||||
Reference in new issue
Block a user