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:
1 parent
7e4c97f639
commit
1dbb0ce406
15 files changed
+992
-18
No files matched your search
@@ -2328,10 +2328,15 @@ DELETE FROM device_file_aliases WHERE id = $1;
|
||||
|
||||
-- Create collection
|
||||
-- name: CreateCollection :one
|
||||
INSERT INTO collections (user_id, name, description, color, icon, auto_assign_rules, view_settings)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
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 *;
|
||||
|
||||
-- A2c §2.4: provenance + playlist flags on an existing collection without
|
||||
-- touching the rest of the row.
|
||||
-- name: UpdateCollectionViewSettings :exec
|
||||
UPDATE collections SET view_settings = $2, updated_at = NOW() WHERE id = $1;
|
||||
|
||||
-- Get collection
|
||||
-- name: GetCollection :one
|
||||
SELECT * FROM collections WHERE id = $1;
|
||||
@@ -2381,11 +2386,46 @@ VALUES ($1, $2, $3)
|
||||
ON CONFLICT (collection_id, media_item_id) DO NOTHING
|
||||
RETURNING *;
|
||||
|
||||
-- A2c: playlist upsert — position set ONLY on insert, so rescans and
|
||||
-- re-adds never clobber the user's manual drag ordering.
|
||||
-- name: AddBookToCollectionOrdered :one
|
||||
INSERT INTO collection_items (collection_id, media_item_id, added_by_user_id, position)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (collection_id, media_item_id) DO NOTHING
|
||||
RETURNING *;
|
||||
|
||||
-- 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.
|
||||
-- name: ReorderCollectionItems :exec
|
||||
UPDATE collection_items AS ci
|
||||
SET position = d.ord
|
||||
FROM unnest(sqlc.arg('media_item_ids')::uuid[]) WITH ORDINALITY AS d(media_item_id, ord)
|
||||
WHERE ci.collection_id = sqlc.arg('collection_id')::uuid
|
||||
AND ci.media_item_id = d.media_item_id;
|
||||
|
||||
-- A2c §2.4: playlist-collection discovery — provenance key first so a user
|
||||
-- rename can't spawn duplicates on the next scan.
|
||||
-- name: GetCollectionByImportedSeries :one
|
||||
SELECT * FROM collections
|
||||
WHERE user_id = sqlc.arg('user_id')
|
||||
AND view_settings->>'imported_from_series' = sqlc.arg('imported_from_series')::text
|
||||
AND deleted_at IS NULL
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 1;
|
||||
|
||||
-- name: GetCollectionByUserAndName :one
|
||||
SELECT * FROM collections
|
||||
WHERE user_id = $1 AND name = $2 AND deleted_at IS NULL
|
||||
LIMIT 1;
|
||||
|
||||
-- Remove book from collection
|
||||
-- name: RemoveBookFromCollection :exec
|
||||
DELETE FROM collection_items WHERE collection_id = $1 AND media_item_id = $2;
|
||||
|
||||
-- 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.
|
||||
-- name: GetCollectionItems :many
|
||||
SELECT ci.*, mi.title, mi.author, mi.cover_image_path, mi.library_id, mi.format_group
|
||||
FROM collection_items ci
|
||||
@@ -2393,7 +2433,7 @@ JOIN media_items mi ON ci.media_item_id = mi.id
|
||||
WHERE ci.collection_id = $1
|
||||
AND mi.archived_at IS NULL
|
||||
AND mi.missing_scan_count = 0
|
||||
ORDER BY ci.added_at DESC;
|
||||
ORDER BY ci.position ASC NULLS LAST, ci.added_at ASC;
|
||||
|
||||
-- Get collections for book
|
||||
-- name: GetCollectionsForBook :many
|
||||
@@ -2710,9 +2750,12 @@ WHERE user_id = $1
|
||||
ORDER BY priority ASC;
|
||||
|
||||
-- name: GetUserCollectionsForDashboard :many
|
||||
-- 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.
|
||||
SELECT c.* FROM collections c
|
||||
WHERE c.user_id = $1
|
||||
AND c.show_on_dashboard = true
|
||||
AND c.is_system_collection = false
|
||||
AND c.deleted_at IS NULL
|
||||
ORDER BY priority ASC;
|
||||
@@ -2828,16 +2871,47 @@ LIMIT sqlc.narg('limit');
|
||||
-- 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).
|
||||
SELECT sqlc.embed(mi) FROM media_items mi
|
||||
-- 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).
|
||||
SELECT sqlc.embed(mi), rp.percentage AS viewer_percentage FROM media_items mi
|
||||
INNER JOIN collection_items ci ON ci.media_item_id = mi.id
|
||||
LEFT JOIN reading_progress rp ON rp.media_item_id = mi.id AND rp.user_id = sqlc.narg('viewer_user_id')::uuid
|
||||
WHERE ci.collection_id = sqlc.narg('collection_id')
|
||||
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||
AND mi.archived_at IS NULL
|
||||
AND mi.missing_scan_count = 0
|
||||
AND (ci.excluded IS NOT TRUE)
|
||||
ORDER BY ci.added_at DESC
|
||||
AND (sqlc.narg('hide_finished')::boolean IS NOT TRUE OR COALESCE(rp.percentage, 0) < 0.999)
|
||||
ORDER BY ci.position ASC NULLS LAST, ci.added_at ASC
|
||||
LIMIT sqlc.narg('limit');
|
||||
|
||||
-- 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).
|
||||
-- name: GetCollectionUpNext :many
|
||||
SELECT sqlc.embed(mi), rp.percentage AS viewer_percentage FROM media_items mi
|
||||
INNER JOIN collection_items ci ON ci.media_item_id = mi.id
|
||||
LEFT JOIN reading_progress rp ON rp.media_item_id = mi.id AND rp.user_id = sqlc.narg('viewer_user_id')::uuid
|
||||
WHERE ci.collection_id = sqlc.narg('collection_id')
|
||||
AND mi.archived_at IS NULL
|
||||
AND mi.missing_scan_count = 0
|
||||
AND (ci.excluded IS NOT TRUE)
|
||||
AND COALESCE(rp.percentage, 0) < 0.999
|
||||
AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR mi.library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))
|
||||
ORDER BY ci.position ASC NULLS LAST, ci.added_at ASC
|
||||
LIMIT sqlc.narg('limit');
|
||||
|
||||
-- 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.
|
||||
-- name: CountCollectionItems :one
|
||||
SELECT COUNT(*) FROM collection_items WHERE collection_id = $1;
|
||||
|
||||
-- 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
|
||||
|
||||
Reference in new issue
Block a user