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
@@ -24,6 +24,34 @@ func NewDashboardService(db *database.Queries) *DashboardService {
|
||||
}
|
||||
}
|
||||
|
||||
// viewSettingsFlag reads a boolean key from a collection's view_settings
|
||||
// JSONB. Malformed or absent → false.
|
||||
func viewSettingsFlag(raw []byte, key string) bool {
|
||||
if len(raw) == 0 {
|
||||
return false
|
||||
}
|
||||
var settings map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &settings); err != nil {
|
||||
return false
|
||||
}
|
||||
b, ok := settings[key].(bool)
|
||||
return ok && b
|
||||
}
|
||||
|
||||
// viewSettingsHasKey reports whether the key exists at all (any value) —
|
||||
// used for provenance markers like imported_from_series.
|
||||
func viewSettingsHasKey(raw []byte, key string) bool {
|
||||
if len(raw) == 0 {
|
||||
return false
|
||||
}
|
||||
var settings map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &settings); err != nil {
|
||||
return false
|
||||
}
|
||||
_, ok := settings[key]
|
||||
return ok
|
||||
}
|
||||
|
||||
type DashboardSection struct {
|
||||
CollectionID uuid.UUID
|
||||
CollectionName string
|
||||
@@ -34,6 +62,13 @@ type DashboardSection struct {
|
||||
Title string
|
||||
Description string
|
||||
Icon string
|
||||
// A2c: the collection's own ordering-construct marker — the section is
|
||||
// a curated playlist ("The Witcher (Reading Order)"), rendered with a
|
||||
// badge by the frontend.
|
||||
IsPlaylist bool
|
||||
// A2c §3.5: hide_finished filtered every item out of a NON-empty
|
||||
// collection — render "playlist complete" instead of dropping silently.
|
||||
Complete bool
|
||||
}
|
||||
|
||||
func (s *DashboardService) GetDashboardSections(
|
||||
@@ -81,7 +116,29 @@ func (s *DashboardService) GetDashboardSections(
|
||||
continue
|
||||
}
|
||||
|
||||
// A2c §3.5: hide_finished filtered everything out — but the
|
||||
// collection is NOT empty. That's a finished playlist: render a
|
||||
// completion marker instead of silently vanishing.
|
||||
if len(items) == 0 {
|
||||
hideFinished := viewSettingsFlag(coll.ViewSettings, "hide_finished")
|
||||
if hideFinished {
|
||||
collUUIDBytes, _ := uuid.FromBytes(coll.ID.Bytes[0:16])
|
||||
if count, countErr := s.db.CountCollectionItems(ctx, pgtype.UUID{Bytes: collUUIDBytes, Valid: true}); countErr == nil && count > 0 {
|
||||
results = append(results, DashboardSection{
|
||||
CollectionID: coll.ID.Bytes,
|
||||
CollectionName: coll.Name,
|
||||
Items: []database.MediaItems{},
|
||||
QueryType: coll.QueryType.String,
|
||||
Priority: int(coll.Priority.Int32),
|
||||
IsSystem: false,
|
||||
Title: coll.Name,
|
||||
Description: coll.Description.String,
|
||||
Icon: coll.Icon.String,
|
||||
IsPlaylist: viewSettingsHasKey(coll.ViewSettings, "imported_from_series"),
|
||||
Complete: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -95,6 +152,7 @@ func (s *DashboardService) GetDashboardSections(
|
||||
Title: coll.Name,
|
||||
Description: coll.Description.String,
|
||||
Icon: coll.Icon.String,
|
||||
IsPlaylist: viewSettingsHasKey(coll.ViewSettings, "imported_from_series"),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -218,9 +276,15 @@ func (s *DashboardService) getCollectionItemsByQueryType(ctx context.Context, co
|
||||
func (s *DashboardService) getUserCollectionItems(ctx context.Context, coll database.Collections, userID uuid.UUID, libraryID pgtype.UUID, limit int) ([]database.MediaItems, error) {
|
||||
collUUID, _ := uuid.FromBytes(coll.ID.Bytes[0:16])
|
||||
|
||||
// A2c §3.3: per-collection hide_finished (view_settings), evaluated
|
||||
// against the VIEWER's reading progress. The section then shows the
|
||||
// head of the reading queue in playlist order.
|
||||
hideFinished := viewSettingsFlag(coll.ViewSettings, "hide_finished")
|
||||
manualItems, err := s.db.GetCollectionItemsForDashboard(ctx, database.GetCollectionItemsForDashboardParams{
|
||||
CollectionID: pgtype.UUID{Bytes: collUUID, Valid: true},
|
||||
LibraryID: libraryID,
|
||||
ViewerUserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||
HideFinished: pgtype.Bool{Bool: hideFinished, Valid: true},
|
||||
Limit: pgtype.Int4{Int32: int32(limit), Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
Reference in new issue
Block a user