feat(collections): playlist auto-creation, position ordering, hide_finished, Up Next, dashboard eligibility

Phase 2b + 3 of the metadata overhaul (plan §2.4, §3.1-3.7).

Auto-playlist collections (§2.4): the scanner turns obviously-playlist
sidecar series (explicit '(Reading Order)'/chronological/publication/
in-order/timeline markers only — precision over recall) into curated
static collections: upsert by view_settings.imported_from_series then
name (rename-safe), position set ONLY on insert (clean integers; 1.5/1a
stay unpositioned rather than guessing), membership add-if-missing,
never removed or re-ordered by rescans, provenance in description +
view_settings, show_on_dashboard true (dashboard-eligible by design),
'scanner.auto_playlist_collections' setting (default on, Settings
Registry + schema seed).

Ordering (§3.1-3.2): collection listings order position ASC NULLS LAST
then added_at ASC (was added_at DESC — walked playlists backwards);
PUT /api/collections/:id/items/order applies full-order rewrites
(owner-only, service-gated).

hide_finished + Up Next (§3.3-3.4): GetCollectionItemsForDashboard
gains viewer_user_id + hide_finished (LEFT JOIN reading_progress,
COALESCE percentage < 0.999) and viewer_percentage rides the row for
card graying; GetCollectionUpNext serves the collection's reading-queue
head with the family-lens visible-library array enforced.

Dashboard (§3.5): user collections are dashboard-ELIGIBLE BY DEFAULT —
the show_on_dashboard gate (never writable for user rows since the
column shipped) is gone from GetUserCollectionsForDashboard; hiding
stays dashboard-prefs (hidden_collections) territory; hide_finished
filtered-everything now emits a Complete section instead of silently
vanishing; sections JSON carries is_playlist/complete.

Series page ordering hardening (§3.6): GetSeriesBooks tiebreaks by
title ASC — NULL/tied numbers previously returned heap (scan) order,
which is what the user saw.

Frontend custom-section-builder note: the builder has sent
show_on_dashboard: true since its creation; the API never bound it.
User-row visibility is now intentionally prefs-governed, so the field
stays unbound by design (documented in code).

Pins widened (docs/contract_pins.md): collection detail + up_next;
dashboard sections + is_playlist/complete; new reorder endpoint
recorded.

Verified: TestPlaylistCollectionsEndToEnd (new, full e2e: sidecar
series split, collection auto-creation with position 7, rescan
idempotency, owner-only reorder, finished-filter + completion marker +
Up Next drain) plus the full affected integration sweep green.
This commit is contained in:
John O'Keefe committed 2026-10-19 15:25:40 -04:00
1 parent 7e4c97f639
commit 1dbb0ce406
15 files changed
+992 -18

No files matched your search

+35
View File
@@ -14,6 +14,9 @@ type Querier interface {
// COLLECTION ITEMS QUERIES
// Add book to collection
AddBookToCollection(ctx context.Context, arg AddBookToCollectionParams) (CollectionItems, error)
// A2c: playlist upsert — position set ONLY on insert, so rescans and
// re-adds never clobber the user's manual drag ordering.
AddBookToCollectionOrdered(ctx context.Context, arg AddBookToCollectionOrderedParams) (CollectionItems, error)
// ============================================
// KOBO SHELF MANAGEMENT QUERIES
// ============================================
@@ -42,6 +45,10 @@ type Querier interface {
ClearMediaItemMetadataOverrides(ctx context.Context, id pgtype.UUID) error
CountAdmins(ctx context.Context) (int64, error)
CountArchivedMediaItems(ctx context.Context) (int64, error)
// A2c §3.5: distinguish "playlist complete" from "genuinely empty" — the
// dashboard renders a completion marker when hide_finished filtered
// everything out of a non-empty collection.
CountCollectionItems(ctx context.Context, collectionID pgtype.UUID) (int64, error)
// Live item count for a STATIC collection (rule-free cards on the
// collections page); same live filter as GetCollectionItems, plus the
// future-hybrid excluded exclusion and an optional library scope.
@@ -174,12 +181,30 @@ type Querier interface {
GetBooksByTag(ctx context.Context, arg GetBooksByTagParams) ([]MediaItems, error)
// Get collection
GetCollection(ctx context.Context, id pgtype.UUID) (Collections, error)
// A2c §2.4: playlist-collection discovery — provenance key first so a user
// rename can't spawn duplicates on the next scan.
GetCollectionByImportedSeries(ctx context.Context, arg GetCollectionByImportedSeriesParams) (Collections, error)
GetCollectionByUserAndName(ctx context.Context, arg GetCollectionByUserAndNameParams) (Collections, error)
// Get collection items
// A2c: position-first ordering (playlist reading order); un-positioned
// legacy items keep added_at ASC after the positioned tail — NOT DESC,
// which walked a playlist backwards.
GetCollectionItems(ctx context.Context, collectionID pgtype.UUID) ([]GetCollectionItemsRow, error)
// The excluded filter lives here (was: callers filtered ci.excluded in
// Go over a 69-field row + a 45-field converter to MediaItems; embed
// returns the real row so neither exists).
// A2c: position-first ordering; optional hide_finished — when the flag is
// set AND a viewer is supplied, items the viewer finished (percentage
// >= 0.999) drop out, turning the section into the reading queue head.
// viewer_user_id NULL = no progress filter (never-finished semantics for
// anonymous/legacy callers is NOT implied: hide_finished without a viewer
// is a caller bug and shows everything).
GetCollectionItemsForDashboard(ctx context.Context, arg GetCollectionItemsForDashboardParams) ([]GetCollectionItemsForDashboardRow, error)
// A2c §3.4: the collection's reading queue — first N un-finished items in
// position order. Strict playlist semantics: finished items drop out as
// they complete, so the head IS "up next". Library visibility is enforced
// by the family-lens array param (never NULL for user-facing callers).
GetCollectionUpNext(ctx context.Context, arg GetCollectionUpNextParams) ([]GetCollectionUpNextRow, error)
// Get collection with book count
GetCollectionWithBookCount(ctx context.Context, id pgtype.UUID) (GetCollectionWithBookCountRow, error)
// Get collections by user
@@ -393,6 +418,10 @@ type Querier interface {
GetUserByEmail(ctx context.Context, email string) (GetUserByEmailRow, error)
GetUserByEmailOrUsername(ctx context.Context, email string) (GetUserByEmailOrUsernameRow, error)
GetUserByUsername(ctx context.Context, username string) (GetUserByUsernameRow, error)
// A2c (2026-10-19): user collections are dashboard-ELIGIBLE BY DEFAULT —
// show_on_dashboard gated them forever-false (no writer ever set it on
// user rows), and hiding is the dashboard-prefs job (hidden_collections).
// The flag remains system-section provisioning state only.
GetUserCollectionsForDashboard(ctx context.Context, userID pgtype.UUID) ([]Collections, error)
// Per-device reading breakdown for a range. LEFT JOIN so pushes that
// carry no device (web reader until it identifies) still appear as one
@@ -510,6 +539,9 @@ type Querier interface {
// Remove book from collection
RemoveBookFromCollection(ctx context.Context, arg RemoveBookFromCollectionParams) error
RemoveBookFromKoboShelf(ctx context.Context, arg RemoveBookFromKoboShelfParams) error
// A2c: full-order rewrite for the drag-to-reorder endpoint. The payload is
// the complete ordered id list; positions become 1..n in payload order.
ReorderCollectionItems(ctx context.Context, arg ReorderCollectionItemsParams) error
// Re-parent all child rows of p_source onto p_target (defined in schema.sql)
ReparentMediaItemChildren(ctx context.Context, arg ReparentMediaItemChildrenParams) error
ResetSystemCollectionMetadata(ctx context.Context, arg ResetSystemCollectionMetadataParams) error
@@ -556,6 +588,9 @@ type Querier interface {
// 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)
// A2c §2.4: provenance + playlist flags on an existing collection without
// touching the rest of the row.
UpdateCollectionViewSettings(ctx context.Context, arg UpdateCollectionViewSettingsParams) error
UpdateDashboardPreferences(ctx context.Context, arg UpdateDashboardPreferencesParams) (UserDashboardPreferences, error)
UpdateDevice(ctx context.Context, arg UpdateDeviceParams) (Devices, error)
UpdateDeviceAuthToken(ctx context.Context, arg UpdateDeviceAuthTokenParams) (Devices, error)