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.
326 lines
10 KiB
Go
326 lines
10 KiB
Go
package handlers
|
|
|
|
import (
|
|
"bookhoard/internal/database"
|
|
"bookhoard/internal/services"
|
|
"context"
|
|
"log"
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/labstack/echo/v5"
|
|
)
|
|
|
|
type DashboardHandler struct {
|
|
db *database.Queries
|
|
dashboardService *services.DashboardService
|
|
}
|
|
|
|
func NewDashboardHandler(db *database.Queries) *DashboardHandler {
|
|
return &DashboardHandler{
|
|
db: db,
|
|
dashboardService: services.NewDashboardService(db),
|
|
}
|
|
}
|
|
|
|
func (h *DashboardHandler) GetSections(c *echo.Context) error {
|
|
user := c.Get("user").(database.Users)
|
|
userUUID := uuid.UUID(user.ID.Bytes)
|
|
|
|
libraryID := c.QueryParam("library_id")
|
|
var libUUID pgtype.UUID
|
|
if libraryID != "" {
|
|
parsed, err := uuid.Parse(libraryID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
|
|
}
|
|
libUUID = pgtype.UUID{Bytes: parsed, Valid: true}
|
|
}
|
|
|
|
prefs, _ := h.dashboardService.GetDashboardPreferences(c.Request().Context(), userUUID, libUUID)
|
|
|
|
limit := 20
|
|
if prefs.ItemsPerSection.Valid {
|
|
limit = int(prefs.ItemsPerSection.Int32)
|
|
}
|
|
if limitStr := c.QueryParam("limit"); limitStr != "" {
|
|
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 {
|
|
limit = l
|
|
}
|
|
}
|
|
|
|
sections, err := h.dashboardService.GetDashboardSections(
|
|
c.Request().Context(),
|
|
userUUID,
|
|
libUUID,
|
|
limit,
|
|
prefs.CollectionOrder,
|
|
prefs.HiddenCollections,
|
|
)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to load dashboard sections"})
|
|
}
|
|
|
|
// Per-type wording: system-collection display swaps on audiobook scope.
|
|
libraryType := ""
|
|
if libraryID != "" {
|
|
if pgLibID, perr := uuid.Parse(libraryID); perr == nil {
|
|
if lib, lerr := h.db.GetLibrary(c.Request().Context(), pgtype.UUID{Bytes: pgLibID, Valid: true}); lerr == nil {
|
|
libraryType = lib.TypeName
|
|
}
|
|
}
|
|
}
|
|
libTypes := LibraryTypeMap(c.Request().Context(), h.db)
|
|
|
|
sectionData := BuildSections(sections, libraryID, libraryType, libTypes)
|
|
sectionData = MarkActiveConflictsSections(c.Request().Context(), h.db, user.ID, sectionData)
|
|
|
|
return c.JSON(http.StatusOK, map[string]interface{}{"sections": sectionData})
|
|
}
|
|
|
|
func (h *DashboardHandler) UpdatePreferences(c *echo.Context) error {
|
|
user := c.Get("user").(database.Users)
|
|
userUUID := uuid.UUID(user.ID.Bytes)
|
|
|
|
var req struct {
|
|
// nil (JSON null/absent) = the global All Libraries scope;
|
|
// empty string is a LOST value, not a scope — rejected loudly.
|
|
LibraryID *string `json:"library_id"`
|
|
HiddenCollections []string `json:"hidden_collections"`
|
|
CollectionOrder []string `json:"collection_order"`
|
|
ItemsPerSection int `json:"items_per_section"`
|
|
}
|
|
|
|
if err := c.Bind(&req); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request"})
|
|
}
|
|
|
|
var libUUID pgtype.UUID
|
|
if req.LibraryID != nil {
|
|
if *req.LibraryID == "" {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
|
|
}
|
|
parsed, err := uuid.Parse(*req.LibraryID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
|
|
}
|
|
libUUID = pgtype.UUID{Bytes: parsed, Valid: true}
|
|
}
|
|
|
|
// Sanitize preferences (remove duplicates)
|
|
cleanHidden, cleanOrder := h.dashboardService.SanitizeDashboardPreferences(req.HiddenCollections, req.CollectionOrder)
|
|
req.HiddenCollections = cleanHidden
|
|
req.CollectionOrder = cleanOrder
|
|
|
|
prefs, err := h.dashboardService.UpsertDashboardPreferences(c.Request().Context(), database.UpsertDashboardPreferencesParams{
|
|
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
|
LibraryID: libUUID,
|
|
HiddenCollections: req.HiddenCollections,
|
|
CollectionOrder: req.CollectionOrder,
|
|
ItemsPerSection: pgtype.Int4{Int32: int32(req.ItemsPerSection), Valid: true},
|
|
})
|
|
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to save preferences"})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, prefs)
|
|
}
|
|
|
|
func (h *DashboardHandler) RestoreSystemCollection(c *echo.Context) error {
|
|
user := c.Get("user").(database.Users)
|
|
userUUID := uuid.UUID(user.ID.Bytes)
|
|
|
|
var req struct {
|
|
CollectionName string `form:"collection_name" json:"collection_name"`
|
|
ResetType string `form:"reset_type" json:"reset_type"`
|
|
}
|
|
|
|
if err := c.Bind(&req); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request"})
|
|
}
|
|
|
|
if req.CollectionName == "" {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "collection_name required"})
|
|
}
|
|
|
|
if req.ResetType == "" {
|
|
req.ResetType = "full"
|
|
}
|
|
|
|
validCollections := map[string]bool{
|
|
"Continue Reading": true,
|
|
"Recently Added": true,
|
|
"Recently Read": true,
|
|
"Not Started": true,
|
|
"Continue Series": true,
|
|
}
|
|
if !validCollections[req.CollectionName] {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid system collection name"})
|
|
}
|
|
|
|
if req.ResetType != "full" && req.ResetType != "keep_books" {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "reset_type must be 'full' or 'keep_books'"})
|
|
}
|
|
|
|
err := h.dashboardService.RestoreSystemCollection(c.Request().Context(), userUUID, req.CollectionName, req.ResetType)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to restore system collection"})
|
|
}
|
|
|
|
// Add redirect header for HTMX requests
|
|
if c.Request().Header.Get("HX-Request") == "true" {
|
|
c.Response().Header().Set("HX-Redirect", "/collections")
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]string{"message": "System collection restored to defaults"})
|
|
}
|
|
|
|
// BuildSections maps dashboard service sections to card data. When the
|
|
// active library is audiobooks, system-collection titles/descriptions
|
|
// swap to the listening wording at display time — the stored names stay
|
|
// generic so restores and settings keep working across library types.
|
|
func BuildSections(sections []services.DashboardSection, currentLibraryID, libraryType string, libTypes map[string]string) []SectionData {
|
|
var result []SectionData
|
|
|
|
audioScope := libraryType == "audiobooks"
|
|
|
|
for _, ds := range sections {
|
|
if audioScope && ds.IsSystem {
|
|
switch ds.Title {
|
|
case "Continue Reading":
|
|
ds.Title = "Continue Listening"
|
|
ds.Description = "Audiobooks you're currently listening to (0 < progress < 1)"
|
|
case "Recently Read":
|
|
ds.Title = "Recently Listened"
|
|
ds.Description = "Audiobooks you've finished (progress >= 1)"
|
|
}
|
|
}
|
|
|
|
bookCards := BookCardsFromMediaItems(ds.Items, libTypes)
|
|
|
|
result = append(result, SectionData{
|
|
ID: ds.CollectionName,
|
|
CollectionID: ds.CollectionID.String(),
|
|
IsSystem: ds.IsSystem,
|
|
Title: ds.Title,
|
|
Description: ds.Description,
|
|
Icon: ds.Icon,
|
|
Items: bookCards,
|
|
ViewAllURL: getViewAllURL(ds.CollectionID.String(), currentLibraryID),
|
|
Priority: ds.Priority,
|
|
IsPlaylist: ds.IsPlaylist,
|
|
Complete: ds.Complete,
|
|
})
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// activeConflictSet returns the set of media item IDs (as strings) that have an
|
|
// active (unresolved) progress sync conflict for the given user. A single query
|
|
// is issued; resolved conflicts are filtered out in memory.
|
|
func activeConflictSet(ctx context.Context, db *database.Queries, userID pgtype.UUID) map[string]bool {
|
|
conflicts, err := db.ListSyncConflictsByUser(ctx, userID)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
set := make(map[string]bool, len(conflicts))
|
|
for _, c := range conflicts {
|
|
if c.ResolutionStatus.String == "unresolved" {
|
|
set[uuid.UUID(c.MediaItemID.Bytes).String()] = true
|
|
}
|
|
}
|
|
return set
|
|
}
|
|
|
|
// MarkActiveConflicts stamps HasConflict on each book whose media item has an
|
|
// active progress sync conflict for the user. It performs a single query
|
|
// regardless of how many books are passed.
|
|
func MarkActiveConflicts(ctx context.Context, db *database.Queries, userID pgtype.UUID, books []BookInfo) []BookInfo {
|
|
if len(books) == 0 {
|
|
return books
|
|
}
|
|
set := activeConflictSet(ctx, db, userID)
|
|
for i := range books {
|
|
if set[books[i].MediaItemID] {
|
|
books[i].HasConflict = true
|
|
}
|
|
}
|
|
return books
|
|
}
|
|
|
|
// MarkActiveConflictsSections is the section-aware variant of MarkActiveConflicts,
|
|
// used by the dashboard which renders books grouped into sections.
|
|
func MarkActiveConflictsSections(ctx context.Context, db *database.Queries, userID pgtype.UUID, sections []SectionData) []SectionData {
|
|
if len(sections) == 0 {
|
|
return sections
|
|
}
|
|
set := activeConflictSet(ctx, db, userID)
|
|
if len(set) == 0 {
|
|
return sections
|
|
}
|
|
for s := range sections {
|
|
for i := range sections[s].Items {
|
|
if set[sections[s].Items[i].MediaItemID] {
|
|
sections[s].Items[i].HasConflict = true
|
|
}
|
|
}
|
|
}
|
|
return sections
|
|
}
|
|
|
|
func getViewAllURL(collectionID string, libraryID string) string {
|
|
if collectionID != "" {
|
|
if libraryID != "" {
|
|
return "/collections/" + collectionID + "?library_id=" + libraryID
|
|
}
|
|
return "/collections/" + collectionID
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (h *DashboardHandler) GetPreferences(c *echo.Context) error {
|
|
user := c.Get("user").(database.Users)
|
|
userUUID := uuid.UUID(user.ID.Bytes)
|
|
libraryID := c.QueryParam("library_id")
|
|
|
|
var libUUID pgtype.UUID
|
|
if libraryID != "" {
|
|
parsed, err := uuid.Parse(libraryID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
|
|
}
|
|
libUUID = pgtype.UUID{Bytes: parsed, Valid: true}
|
|
}
|
|
|
|
prefs, err := h.dashboardService.GetDashboardPreferences(c.Request().Context(), userUUID, libUUID)
|
|
if err != nil {
|
|
// Return default preferences instead of 404 when none exist
|
|
log.Printf("GetDashboardPreferences failed: %v", err)
|
|
prefs = database.UserDashboardPreferences{
|
|
HiddenCollections: []string{},
|
|
CollectionOrder: []string{},
|
|
ItemsPerSection: pgtype.Int4{Int32: 20, Valid: true},
|
|
}
|
|
}
|
|
|
|
// Normalize nil slices to empty arrays for JSON
|
|
hiddenCollections := prefs.HiddenCollections
|
|
if hiddenCollections == nil {
|
|
hiddenCollections = []string{}
|
|
}
|
|
collectionOrder := prefs.CollectionOrder
|
|
if collectionOrder == nil {
|
|
collectionOrder = []string{}
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
|
"hidden_collections": hiddenCollections,
|
|
"collection_order": collectionOrder,
|
|
"items_per_section": prefs.ItemsPerSection.Int32,
|
|
})
|
|
}
|