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
@@ -79,6 +79,10 @@ type SectionData struct {
|
||||
Items []BookInfo `json:"items"`
|
||||
ViewAllURL string `json:"view_all_url"`
|
||||
Priority int `json:"priority"`
|
||||
// A2c: curated playlist marker (imported reading-order collection) and
|
||||
// the hide_finished completion state — dashboard.ts renders both.
|
||||
IsPlaylist bool `json:"is_playlist"`
|
||||
Complete bool `json:"complete"`
|
||||
}
|
||||
|
||||
func (h *CollectionHandler) CreateCollection(c *echo.Context) error {
|
||||
@@ -284,10 +288,28 @@ func (h *CollectionHandler) GetCollection(c *echo.Context) error {
|
||||
}
|
||||
}
|
||||
} else if libUUID.Valid {
|
||||
// A2c §3.3: the detail page honors the same per-collection
|
||||
// hide_finished as the dashboard section, against the viewer's
|
||||
// progress. The viewer_percentage rides along for card graying.
|
||||
hideFinished := false
|
||||
if len(collection.ViewSettings) > 0 {
|
||||
var vs map[string]interface{}
|
||||
if json.Unmarshal(collection.ViewSettings, &vs) == nil {
|
||||
if b, ok := vs["hide_finished"].(bool); ok {
|
||||
hideFinished = b
|
||||
}
|
||||
}
|
||||
}
|
||||
var viewerID pgtype.UUID
|
||||
if u, ok := c.Get("user").(database.Users); ok {
|
||||
viewerID = u.ID
|
||||
}
|
||||
collItems, collErr := h.db.GetCollectionItemsForDashboard(c.Request().Context(),
|
||||
database.GetCollectionItemsForDashboardParams{
|
||||
CollectionID: pgtype.UUID{Bytes: collectionID, Valid: true},
|
||||
LibraryID: libUUID,
|
||||
ViewerUserID: viewerID,
|
||||
HideFinished: pgtype.Bool{Bool: hideFinished, Valid: hideFinished},
|
||||
Limit: pgtype.Int4{Int32: 10000, Valid: true},
|
||||
})
|
||||
if collErr != nil {
|
||||
@@ -317,6 +339,40 @@ func (h *CollectionHandler) GetCollection(c *echo.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
// A2c §3.4: the collection's reading queue for the Up Next strip.
|
||||
// Library visibility enforced — a hidden library's books never surface
|
||||
// here even if they sit in a visible collection.
|
||||
upNext := []map[string]interface{}{}
|
||||
if !isSmart {
|
||||
hideFinished := false
|
||||
if b, ok := viewSettings["hide_finished"].(bool); ok {
|
||||
hideFinished = b
|
||||
}
|
||||
_ = hideFinished
|
||||
visibleIDs, visErr := h.userVisibleLibraryIDs(c)
|
||||
if visErr == nil {
|
||||
user := c.Get("user").(database.Users)
|
||||
rows, upErr := h.db.GetCollectionUpNext(c.Request().Context(), database.GetCollectionUpNextParams{
|
||||
CollectionID: pgtype.UUID{Bytes: collectionID, Valid: true},
|
||||
ViewerUserID: pgtype.UUID{Bytes: user.ID.Bytes, Valid: true},
|
||||
VisibleLibraryIds: visibleIDs,
|
||||
Limit: pgtype.Int4{Int32: 10, Valid: true},
|
||||
})
|
||||
if upErr == nil {
|
||||
for _, row := range rows {
|
||||
upNext = append(upNext, map[string]interface{}{
|
||||
"id": uuid.UUID(row.MediaItems.ID.Bytes).String(),
|
||||
"title": row.MediaItems.Title,
|
||||
"author": utils.PgTextString(row.MediaItems.Author),
|
||||
"cover": utils.PgTextString(row.MediaItems.CoverImagePath),
|
||||
"format": row.MediaItems.FormatGroup,
|
||||
"progress": row.ViewerPercentage,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"id": uuid.UUID(collection.ID.Bytes).String(),
|
||||
"user_id": uuid.UUID(collection.UserID.Bytes).String(),
|
||||
@@ -329,6 +385,7 @@ func (h *CollectionHandler) GetCollection(c *echo.Context) error {
|
||||
"books": bookList,
|
||||
"book_count": len(bookList),
|
||||
"is_smart": isSmart,
|
||||
"up_next": upNext,
|
||||
"created_at": collection.CreatedAt.Time.String(),
|
||||
})
|
||||
}
|
||||
@@ -1238,3 +1295,44 @@ func (h *CollectionHandler) PreviewCollection(c *echo.Context) error {
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{"items": bookCards})
|
||||
}
|
||||
|
||||
// ReorderItems applies a static collection's manual playlist order
|
||||
// (A2c §3.2). Owner-only; the payload is the COMPLETE ordered id list —
|
||||
// ids omitted keep their relative order appended after (the service never
|
||||
// deletes membership).
|
||||
func (h *CollectionHandler) ReorderItems(c *echo.Context) error {
|
||||
collectionID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"})
|
||||
}
|
||||
user, ok := c.Get("user").(database.Users)
|
||||
if !ok {
|
||||
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
|
||||
}
|
||||
|
||||
var req struct {
|
||||
ItemIDs []string `json:"item_ids"`
|
||||
}
|
||||
if err := c.Bind(&req); err != nil || len(req.ItemIDs) == 0 {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "item_ids must be a non-empty ordered id list"})
|
||||
}
|
||||
|
||||
owns, err := h.collectionService.UserOwnsCollection(c.Request().Context(), collectionID, uuid.UUID(user.ID.Bytes))
|
||||
if err != nil || !owns {
|
||||
return c.JSON(http.StatusForbidden, map[string]string{"error": "only the collection owner can reorder it"})
|
||||
}
|
||||
|
||||
ordered := make([]uuid.UUID, 0, len(req.ItemIDs))
|
||||
for _, raw := range req.ItemIDs {
|
||||
id, parseErr := uuid.Parse(raw)
|
||||
if parseErr != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id: " + raw})
|
||||
}
|
||||
ordered = append(ordered, id)
|
||||
}
|
||||
|
||||
if err := h.collectionService.ReorderItems(c.Request().Context(), collectionID, ordered); err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]string{"status": "reordered"})
|
||||
}
|
||||
@@ -211,6 +211,8 @@ func BuildSections(sections []services.DashboardSection, currentLibraryID, libra
|
||||
Items: bookCards,
|
||||
ViewAllURL: getViewAllURL(ds.CollectionID.String(), currentLibraryID),
|
||||
Priority: ds.Priority,
|
||||
IsPlaylist: ds.IsPlaylist,
|
||||
Complete: ds.Complete,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user