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

@@ -935,6 +935,7 @@ func (s *MediaScanner) processAudioFolderBook(ctx context.Context, rootDir strin
if err := s.setAudioFormatGroup(ctx, created.ID, firstMime, len(payload.Chapters)); err != nil {
fmt.Printf("Warning: failed to set format group for folder book %s: %v\n", rootRel, err)
}
s.syncPlaylistCollectionsAfterCreate(ctx, created.ID, metadata.PlaylistSeries)
fmt.Printf("[AUDIO] Created folder book '%s' (%d tracks) at %s\n", metadata.Title, len(tracks), rootRel)
s.newItems++
default:
@@ -1057,6 +1058,7 @@ func (s *MediaScanner) processZipAudioBook(ctx context.Context, zipPath string,
if err := s.setAudioFormatGroup(ctx, created.ID, firstMime, len(payload.Chapters)); err != nil {
fmt.Printf("Warning: failed to set format group for zip book %s: %v\n", zipRel, err)
}
s.syncPlaylistCollectionsAfterCreate(ctx, created.ID, metadata.PlaylistSeries)
fmt.Printf("[AUDIO] Created zip book '%s' (%d tracks) at %s\n", metadata.Title, len(entries), zipRel)
s.newItems++
default: