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:
John O'Keefe committed 2026-10-30 16:54:36 -04:00
1 parent f68fb19afb
commit abb7c84833
17 files changed
+694 -204

No files matched your search

+1
View File
@@ -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"`
+7 -2
View File
@@ -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
View File
@@ -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,
+12 -3
View File
@@ -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