Files
bookhoard/internal/handlers/collections.go
T
John O'Keefe cc0e9d7f91 feat(podcasts): the episodes authoring door + curated chain order
"Newest episodes from my selected podcasts" is now buildable in the UI,
end to end — the user could not find ANY podcast-related filter surface
because none existed: the New Collection modal could only create
book-scope smart collections, and the engine's episodes scope (item 3.1)
had no authoring door.

The scope door:
- The create modal's Smart card gains a Rules-scope segmented control
  (📚 Books / 🎧 Podcast episodes); the copy no longer claims smart
  means "match books by rules". Choosing Episodes seeds an EMPTY
  episodes tree ({"scope":"episodes","mode":"all","children":[]}) so the
  collection is born episode-scoped: the editor opens with the episode
  vocabulary (feed/downloaded/played/published date) and membership
  runs the episode engine from the first rule.
- ParseFilterTree now accepts an EMPTY ROOT (nested empty groups stay
  invalid) — the seed's legal shape. The root arrives at depth 1 by
  call convention, hence the depth>1 exemption.
- The composers define the empty-tree semantics: zero children matches
  NOTHING (ComposedLiveMedia/ComposedCount guards), mirroring
  EvaluateSmartCollection's zero-enabled-rules convention. Without the
  guard the composed query degrades to the bare scope head and a fresh
  collection would swallow every episode in scope — pinned by
  TestEpisodeCollectionsEmptySeedMatchesNothing (envelope book_count 0)
  and TestEmptyRootTreeSeedsEpisodesScope.

The curated chain order (plan §10 R2, user need: the briefing plays
NEWEST-first):
- view_settings.chain_order ("native"/"newest"/"oldest"/"reverse")
  stored on the collection — curated content, not a player-side knob.
- PUT /api/collections/:id/view-settings merges the key into the
  stored JSONB (a blind write would clobber the playlist toggles).
- buildSmartCollectionSequenceContext routes OrderEpisodes through
  handlers.CollectionChainOrder; the detail header's Play-as-Sequence
  gains the order select next to it (PlayCollectionButton), which
  PUTs and reloads. Pinned by chain_follows_the_curated_order
  (reverse flips the chain indexes and hops).

Caught by the new test, a latent clobber fixed: UpdateCollection
treated a NIL view_settings payload as "write NULL" — any edit that
carried no settings dropped every stored key (chain_order, playlist
toggles from API clients, imported provenance). Nil now means
untouched, the same convention filterTree already had.

Composer guards also cover ComposedCount, so the builder's live count
reads the same semantics. Tests: collectionChainOrder unit matrix,
empty-root parse unit pin, two integration pins (seed matches nothing;
curated order drives the chain and survives whole-map edits).
2026-10-31 13:39:24 -04:00

1303 lines
45 KiB
Go

package handlers
import (
"bookhoard/internal/database"
"bookhoard/internal/services"
wsync "bookhoard/internal/sync"
"bookhoard/internal/utils"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/labstack/echo/v5"
)
type CollectionHandler struct {
db *database.Queries
collectionService *services.CollectionService
libraryService *services.LibraryService
connManager *wsync.ConnectionManager
}
func NewCollectionHandler(db *database.Queries, libraryService *services.LibraryService, connManager *wsync.ConnectionManager, search ...*services.SearchService) *CollectionHandler {
return &CollectionHandler{
db: db,
collectionService: services.NewCollectionService(db, search...),
libraryService: libraryService,
connManager: connManager,
}
}
type CreateCollectionRequest struct {
Name string `form:"name" json:"name" validate:"required"`
Description string `form:"description" json:"description"`
Color string `form:"color" json:"color"`
Icon string `form:"icon" json:"icon"`
CollectionType string `form:"collection_type" json:"collection_type"` // "static" (default) or "smart" (rule-based)
AutoAssignRules []services.Rule `form:"auto_assign_rules" json:"auto_assign_rules"`
ViewSettings map[string]interface{} `form:"view_settings" json:"view_settings"`
// The unified filter tree (item 3.2): when present the collection's
// smart membership runs through the qf engine. INVALID trees are a
// 400 (ParseFilterTree at the handler); a tree supersedes
// auto_assign_rules. Persisted as-is (any). RawMessage for
// round-trip safety.
FilterTree any `form:"filter_tree" json:"filter_tree"`
}
// treeFilterJSON returns the request's filter tree as JSON storage
// bytes (nil when none): object payloads marshal; strings (a legacy
// double-encoded client, the custom-section builder's known bug class)
// are written verbatim after object-attempt.
func treeFilterJSON(payload any) ([]byte, error) {
switch v := payload.(type) {
case nil:
return nil, nil
case map[string]interface{}:
b, err := json.Marshal(v)
if err != nil {
return nil, err
}
return b, nil
case string:
return []byte(v), nil
default:
return nil, fmt.Errorf("filter_tree must be an object")
}
}
// parseRequestFilterTree validates the request's tree against the
// engine grammar (ParseFilterTree) so an invalid tree is a 400, never
// a stored 500 at render time.
func parseRequestFilterTree(payload any) ([]byte, error) {
b, err := treeFilterJSON(payload)
if err != nil || b == nil {
return b, err
}
if _, err := services.ParseFilterTree(string(b)); err != nil {
return nil, fmt.Errorf("invalid filter_tree: %w", err)
}
return b, nil
}
type UpdateCollectionRequest struct {
Name string `form:"name" json:"name" validate:"required"`
// Smart-collection match semantics (item 2.4): "any" (OR) | "all"
// (AND). Empty/invalid keeps the stored value.
RuleMatchMode string `form:"rule_match_mode" json:"rule_match_mode"`
// The unified filter tree (item 3.2): when present the collection's
// smart membership runs through the qf engine. INVALID trees are a
// 400 (ParseFilterTree at the handler); a tree supersedes
// auto_assign_rules.
FilterTree any `form:"filter_tree" json:"filter_tree"`
Description string `form:"description" json:"description"`
Color string `form:"color" json:"color"`
Icon string `form:"icon" json:"icon"`
AutoAssignRules []services.Rule `form:"auto_assign_rules" json:"auto_assign_rules"`
ViewSettings map[string]interface{} `form:"view_settings" json:"view_settings"`
// A2c §3.3: playlist toggles — merged into view_settings server-side
// (nil = untouched). []string because the form posts a hidden "false"
// sibling next to the checkbox; the LAST value wins. JSON clients may
// send a single-element array (or omit — untouched).
HideFinished []string `form:"hide_finished" json:"hide_finished"`
ShowUpNext []string `form:"show_up_next" json:"show_up_next"`
}
type AddBooksRequest struct {
BookIDs []string `json:"book_ids" validate:"required"`
}
type CreateDeviceMappingRequest struct {
CollectionID string `json:"collection_id" validate:"required"`
DeviceShelfName string `json:"device_shelf_name" validate:"required"`
SyncDirection string `json:"sync_direction" validate:"required,oneof=bidirectional book_to_device device_to_book none"`
}
type UpdateDeviceMappingRequest struct {
DeviceShelfName string `json:"device_shelf_name" validate:"required"`
SyncDirection string `json:"sync_direction" validate:"required,oneof=bidirectional book_to_device device_to_book none"`
}
// DeviceMappingSummary is one entry in GET /api/devices/:id/collections. Hoisted
// to package scope so the docs schema registry can pin the response shape.
type DeviceMappingSummary struct {
ID uuid.UUID `json:"id"`
CollectionID uuid.UUID `json:"collection_id"`
CollectionName string `json:"collection_name"`
DeviceShelfName string `json:"device_shelf_name"`
SyncDirection string `json:"sync_direction"`
CreatedAt string `json:"created_at"`
}
// CollectionSummary is one entry in GET /api/collections. Package-level so
// the docs schema registry (internal/docs) can pin the response shape.
type CollectionSummary struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Color string `json:"color"`
Icon string `json:"icon"`
AutoAssignRules json.RawMessage `json:"auto_assign_rules"`
CreatedAt string `json:"created_at"`
BookCount int `json:"book_count"`
IsSmart bool `json:"is_smart"`
}
// BookCollectionSummary is one entry in GET /api/collections/:id/books —
// the collections a book belongs to (list view: no rules/count fields).
type BookCollectionSummary struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Color string `json:"color"`
Icon string `json:"icon"`
}
type SectionData struct {
ID string `json:"id"`
CollectionID string `json:"collection_id"`
IsSystem bool `json:"is_system"`
Title string `json:"title"`
Description string `json:"description"`
Icon string `json:"icon"`
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 {
user := c.Get("user").(database.Users)
userUUID := uuid.UUID(user.ID.Bytes)
var req CreateCollectionRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
treeJSON, treeErr := parseRequestFilterTree(req.FilterTree)
if treeErr != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": treeErr.Error()})
}
collection, err := h.collectionService.CreateCollection(
c.Request().Context(),
userUUID,
req.Name,
req.Description,
req.Color,
req.Icon,
req.AutoAssignRules,
req.ViewSettings,
treeJSON,
)
if err != nil {
if errors.Is(err, services.ErrCollectionNameTaken) {
return c.JSON(http.StatusConflict, map[string]string{"error": "a collection with this name already exists"})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
bookCount := int32(0)
isSmart := req.CollectionType == "smart" || len(req.AutoAssignRules) > 0
response := map[string]interface{}{
"id": uuid.UUID(collection.ID.Bytes).String(),
"user_id": uuid.UUID(collection.UserID.Bytes).String(),
"name": collection.Name,
"description": utils.PgTextString(collection.Description),
"color": utils.PgTextString(collection.Color),
"icon": utils.PgTextString(collection.Icon),
"collection_type": map[bool]string{true: "smart", false: "static"}[isSmart],
"auto_assign_rules": collection.AutoAssignRules,
"view_settings": collection.ViewSettings,
"book_count": bookCount,
"created_at": collection.CreatedAt.Time.String(),
}
// Add redirect header for HTMX requests
if c.Request().Header.Get("HX-Request") == "true" {
// A smart collection starts with an empty ruleset; the builder is
// where it becomes real. Static collections head back to the grid.
if isSmart {
c.Response().Header().Set("HX-Redirect", "/collections/"+uuid.UUID(collection.ID.Bytes).String()+"/rules")
} else {
c.Response().Header().Set("HX-Redirect", "/collections")
}
}
return c.JSON(http.StatusCreated, response)
}
func (h *CollectionHandler) GetCollections(c *echo.Context) error {
includeAuto := c.QueryParam("include_auto") == "true"
sortBy := c.QueryParam("sort_by")
libraryID := c.QueryParam("library_id")
collections, err := h.GetCollectionsData(c)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
response := make([]CollectionSummary, 0, len(collections))
for _, col := range collections {
isSmart := services.IsCollectionSmart(col)
if !includeAuto && isSmart {
// Legacy default for API clients (the Android app pre-dates
// smart collections): rule-driven collections stay opt-in so
// their membership could never be stale for old readers.
continue
}
bookCount := 0
var libUUID pgtype.UUID
if libraryID != "" {
if libParsed, libErr := uuid.Parse(libraryID); libErr == nil {
libUUID = pgtype.UUID{Bytes: libParsed, Valid: true}
}
}
if isSmart {
visibleIDs, visErr := h.userVisibleLibraryIDs(c)
if visErr == nil {
matches, evalErr := h.collectionService.EvaluateSmartMembership(c.Request().Context(), col, services.SmartEvalOptions{
LibraryID: libUUID,
VisibleIDs: visibleIDs,
MatchMode: col.RuleMatchMode,
ViewerUserID: MustGetAuthenticatedUser(c).ID,
})
if evalErr == nil {
bookCount = len(matches)
}
}
} else {
count, countErr := h.db.CountCollectionLiveItems(c.Request().Context(), database.CountCollectionLiveItemsParams{
CollectionID: pgtype.UUID{Bytes: col.ID.Bytes, Valid: true},
LibraryID: libUUID,
})
if countErr == nil {
bookCount = int(count)
}
}
response = append(response, CollectionSummary{
ID: col.ID.Bytes,
Name: col.Name,
Description: utils.PgTextString(col.Description),
Color: utils.PgTextString(col.Color),
Icon: utils.PgTextString(col.Icon),
AutoAssignRules: col.AutoAssignRules,
CreatedAt: col.CreatedAt.Time.String(),
BookCount: bookCount,
IsSmart: isSmart,
})
}
if sortBy == "name" {
for i := 0; i < len(response); i++ {
for j := i + 1; j < len(response); j++ {
if response[i].Name > response[j].Name {
response[i], response[j] = response[j], response[i]
}
}
}
}
return c.JSON(http.StatusOK, map[string]interface{}{
"collections": response,
"total": len(response),
})
}
func (h *CollectionHandler) GetCollection(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"})
}
collection, err := h.GetCollectionData(c, collectionID)
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": "collection not found"})
}
libraryID := c.QueryParam("library_id")
var bookList []BookInfo
var libUUID pgtype.UUID
if libraryID != "" {
parsed, parseErr := uuid.Parse(libraryID)
if parseErr != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
}
libUUID = pgtype.UUID{Bytes: parsed, Valid: true}
}
isSmart := services.IsCollectionSmart(collection)
libTypes := LibraryTypeMap(c.Request().Context(), h.db)
if isSmart {
visibleIDs, err := h.userVisibleLibraryIDs(c)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
matches, evalErr := h.collectionService.EvaluateSmartMembership(c.Request().Context(), collection, services.SmartEvalOptions{
LibraryID: libUUID,
VisibleIDs: visibleIDs,
MatchMode: collection.RuleMatchMode,
ViewerUserID: MustGetAuthenticatedUser(c).ID,
})
if evalErr != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": evalErr.Error()})
}
bookList = BookCardsFromMediaItems(matches, libTypes)
} else if collection.IsSystemCollection.Bool && collection.QueryType.Valid && collection.QueryType.String != "" && collection.QueryType.String != "filter" {
user := c.Get("user").(database.Users)
userUUID := uuid.UUID(user.ID.Bytes)
dashboardSvc := services.NewDashboardService(h.db)
sections, secErr := dashboardSvc.GetDashboardSections(c.Request().Context(), userUUID, libUUID, 1000, []string{}, []string{})
if secErr != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": secErr.Error()})
}
for _, section := range sections {
if section.CollectionID == collectionID {
bookList = BookCardsFromMediaItems(section.Items, libTypes)
break
}
}
} 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 {
bookList = BookCardsFromCollectionDashRows(nil, libTypes)
} else {
// The query filters excluded rows in SQL; cards build straight
// from the embedded media rows.
bookList = BookCardsFromCollectionDashRows(collItems, libTypes)
}
} else {
books, booksErr := h.GetCollectionBooksData(c, collectionID)
if booksErr != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": booksErr.Error()})
}
bookList = BookCardsFromCollectionItemRows(books, libTypes)
}
if u, ok := c.Get("user").(database.Users); ok {
bookList = MarkActiveConflicts(c.Request().Context(), h.db, u.ID, bookList)
}
var viewSettings map[string]interface{}
if len(collection.ViewSettings) > 0 {
err := json.Unmarshal(collection.ViewSettings, &viewSettings)
if err != nil {
return err
}
}
// 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(),
"name": collection.Name,
"description": utils.PgTextString(collection.Description),
"color": utils.PgTextString(collection.Color),
"icon": utils.PgTextString(collection.Icon),
"auto_assign_rules": json.RawMessage(collection.AutoAssignRules),
"view_settings": viewSettings,
"rule_match_mode": collection.RuleMatchMode,
"filter_tree": treeEnvelope(collection.FilterTree),
"books": bookList,
"book_count": len(bookList),
"is_smart": isSmart,
"up_next": upNext,
"created_at": collection.CreatedAt.Time.String(),
})
}
func (h *CollectionHandler) UpdateCollection(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"})
}
var req UpdateCollectionRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
// A2c §3.3: playlist toggles ride the edit form as top-level fields and
// merge into view_settings server-side (the form can't bind a map).
// The form posts a hidden "false" sibling before the checkbox, so the
// LAST value is the checkbox state.
if v := optionalFlag(req.HideFinished); v != nil {
if req.ViewSettings == nil {
req.ViewSettings = map[string]interface{}{}
}
req.ViewSettings["hide_finished"] = *v
}
if v := optionalFlag(req.ShowUpNext); v != nil {
if req.ViewSettings == nil {
req.ViewSettings = map[string]interface{}{}
}
req.ViewSettings["show_up_next"] = *v
}
treeJSON, treeErr := parseRequestFilterTree(req.FilterTree)
if treeErr != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": treeErr.Error()})
}
collection, err := h.collectionService.UpdateCollection(
c.Request().Context(),
collectionID,
req.Name,
req.Description,
req.Color,
req.Icon,
req.AutoAssignRules,
req.ViewSettings,
req.RuleMatchMode,
treeJSON,
)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
response := map[string]interface{}{
"id": uuid.UUID(collection.ID.Bytes).String(),
"user_id": uuid.UUID(collection.UserID.Bytes).String(),
"name": collection.Name,
"description": utils.PgTextString(collection.Description),
"color": utils.PgTextString(collection.Color),
"icon": utils.PgTextString(collection.Icon),
"auto_assign_rules": json.RawMessage(collection.AutoAssignRules),
"view_settings": json.RawMessage(collection.ViewSettings),
"created_at": collection.CreatedAt.Time.String(),
}
// 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.StatusCreated, response)
}
// ValidChainOrders — the collection chain's listening-order vocabulary
// (plan §10 R2: a collection's playback order is CURATED on the
// collection, not a player-side knob). Mirrors OrderEpisodes' orders;
// "" means native (the pre-chain-order default).
var ValidChainOrders = map[string]bool{"": true, "native": true, "newest": true, "oldest": true, "reverse": true}
// CollectionChainOrder resolves a collection row's curated chain order
// from its view_settings JSONB — "" (native) when absent/unknown. The
// episode chain producer routes OrderEpisodes through this; the
// view-settings PUT writes the key (SetCollectionChainOrder).
func CollectionChainOrder(viewSettings []byte) string {
if len(viewSettings) == 0 {
return ""
}
var vs map[string]interface{}
if json.Unmarshal(viewSettings, &vs) != nil {
return ""
}
if s, ok := vs["chain_order"].(string); ok && ValidChainOrders[s] {
return s
}
return ""
}
// SetCollectionChainOrder — PUT /api/collections/:id/view-settings with
// {"chain_order": "..."}: merges the key into the collection's stored
// view_settings (the playlist toggles share that JSONB — a blind write
// would clobber them). The episode chain producer
// (buildSmartCollectionSequenceContext) honors it.
func (h *CollectionHandler) SetCollectionChainOrder(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"})
}
var req struct {
ChainOrder string `json:"chain_order"`
}
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
if !ValidChainOrders[req.ChainOrder] {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "chain_order must be one of: native, newest, oldest, reverse"})
}
collection, err := h.collectionService.GetCollection(c.Request().Context(), collectionID)
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": "collection not found"})
}
settings := map[string]interface{}{}
if len(collection.ViewSettings) > 0 {
if uerr := json.Unmarshal(collection.ViewSettings, &settings); uerr != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "corrupt view_settings"})
}
}
if req.ChainOrder == "" || req.ChainOrder == "native" {
// Both spellings mean the default — drop the key entirely so the
// stored doc stays clean.
delete(settings, "chain_order")
} else {
settings["chain_order"] = req.ChainOrder
}
blob, err := json.Marshal(settings)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
if err := h.db.UpdateCollectionViewSettings(c.Request().Context(), database.UpdateCollectionViewSettingsParams{
ID: pgtype.UUID{Bytes: collectionID, Valid: true},
ViewSettings: blob,
}); err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]interface{}{
"id": uuid.UUID(collection.ID.Bytes).String(),
"chain_order": req.ChainOrder,
"view_settings": settings,
})
}
func (h *CollectionHandler) DeleteCollection(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"})
}
err = h.collectionService.DeleteCollection(c.Request().Context(), collectionID)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
if c.Request().Header.Get("HX-Request") == "true" {
// CSP: no inline scripts in partials — auth_forms.ts navigates.
html := `<div class="text-green-500" data-redirect="/collections">Collection deleted! Redirecting...</div>`
return c.HTML(http.StatusOK, html)
}
return c.NoContent(http.StatusNoContent)
}
func (h *CollectionHandler) AddBooks(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"})
}
if guardErr := h.rejectSmartMutation(c, collectionID); guardErr != nil {
return guardErr
}
user := c.Get("user").(database.Users)
userUUID := uuid.UUID(user.ID.Bytes)
var req AddBooksRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
addedCount := 0
var addedBookIDs []string
for _, bookIDStr := range req.BookIDs {
bookID, err := uuid.Parse(bookIDStr)
if err != nil {
continue
}
err = h.collectionService.AddBookToCollection(c.Request().Context(), collectionID, bookID, userUUID)
if err == nil {
addedCount++
addedBookIDs = append(addedBookIDs, bookID.String())
}
}
if addedCount > 0 && h.connManager != nil {
h.connManager.BroadcastToUser(userUUID.String(), wsync.BroadcastMessage{
Type: "collection_updated",
Timestamp: time.Now().Format(time.RFC3339),
Data: map[string]interface{}{
"collection_id": collectionID.String(),
"action": "books_added",
"book_ids": addedBookIDs,
"count": addedCount,
},
})
}
return c.NoContent(http.StatusNoContent)
}
func (h *CollectionHandler) RemoveBook(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"})
}
if guardErr := h.rejectSmartMutation(c, collectionID); guardErr != nil {
return guardErr
}
bookID, err := uuid.Parse(c.Param("bookId"))
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid book id"})
}
// Get user from context for WebSocket broadcast
user := c.Get("user").(database.Users)
userUUID := uuid.UUID(user.ID.Bytes)
err = h.collectionService.RemoveBookFromCollection(c.Request().Context(), collectionID, bookID)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
// Broadcast to user's other devices via WebSocket
if h.connManager != nil {
h.connManager.BroadcastToUser(userUUID.String(), wsync.BroadcastMessage{
Type: "collection_updated",
Timestamp: time.Now().Format(time.RFC3339),
Data: map[string]interface{}{
"collection_id": collectionID.String(),
"action": "book_removed",
"book_id": bookID.String(),
},
})
}
return c.NoContent(http.StatusNoContent)
}
type BulkRemoveBooksRequest struct {
BookIDs []string `json:"book_ids" validate:"required"`
}
func (h *CollectionHandler) BulkRemoveBooks(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"})
}
if guardErr := h.rejectSmartMutation(c, collectionID); guardErr != nil {
return guardErr
}
user := c.Get("user").(database.Users)
userUUID := uuid.UUID(user.ID.Bytes)
var req BulkRemoveBooksRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
removedCount := 0
var removedBookIDs []string
for _, bookIDStr := range req.BookIDs {
bookID, err := uuid.Parse(bookIDStr)
if err != nil {
continue
}
err = h.collectionService.RemoveBookFromCollection(c.Request().Context(), collectionID, bookID)
if err == nil {
removedCount++
removedBookIDs = append(removedBookIDs, bookID.String())
}
}
if removedCount > 0 && h.connManager != nil {
h.connManager.BroadcastToUser(userUUID.String(), wsync.BroadcastMessage{
Type: "collection_updated",
Timestamp: time.Now().Format(time.RFC3339),
Data: map[string]interface{}{
"collection_id": collectionID.String(),
"action": "books_removed",
"book_ids": removedBookIDs,
"count": removedCount,
},
})
}
return c.JSON(http.StatusOK, map[string]interface{}{
"removed": removedCount,
"total": len(req.BookIDs),
})
}
func (h *CollectionHandler) GetDeviceMappings(c *echo.Context) error {
deviceID, err := uuid.Parse(c.Param("id"))
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid device id"})
}
mappings, err := h.collectionService.GetDeviceShelfMappings(c.Request().Context(), deviceID)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
response := make([]DeviceMappingSummary, 0, len(mappings))
for _, m := range mappings {
response = append(response, DeviceMappingSummary{
ID: m.ID.Bytes,
CollectionID: m.CollectionID.Bytes,
CollectionName: m.CollectionName,
DeviceShelfName: utils.PgTextString(m.DeviceShelfName),
SyncDirection: utils.PgTextString(m.SyncDirection),
CreatedAt: m.CreatedAt.Time.String(),
})
}
return c.JSON(http.StatusOK, map[string]interface{}{
"device_id": deviceID.String(),
"mappings": response,
"total": len(response),
})
}
func (h *CollectionHandler) CreateDeviceMapping(c *echo.Context) error {
deviceID, err := uuid.Parse(c.Param("id"))
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid device id"})
}
var req CreateDeviceMappingRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
collectionID, err := uuid.Parse(req.CollectionID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"})
}
mapping, err := h.collectionService.CreateDeviceShelfMapping(
c.Request().Context(),
collectionID,
deviceID,
req.DeviceShelfName,
req.SyncDirection,
)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusCreated, map[string]interface{}{
"id": uuid.UUID(mapping.ID.Bytes).String(),
"collection_id": uuid.UUID(mapping.CollectionID.Bytes).String(),
"device_id": uuid.UUID(mapping.DeviceID.Bytes).String(),
"device_shelf_name": utils.PgTextString(mapping.DeviceShelfName),
"sync_direction": utils.PgTextString(mapping.SyncDirection),
"created_at": mapping.CreatedAt.Time.String(),
})
}
func (h *CollectionHandler) UpdateDeviceMapping(c *echo.Context) error {
mappingID, err := uuid.Parse(c.Param("mappingId"))
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid mapping id"})
}
var req UpdateDeviceMappingRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
mapping, err := h.collectionService.UpdateDeviceShelfMapping(
c.Request().Context(),
mappingID,
req.DeviceShelfName,
req.SyncDirection,
)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]interface{}{
"id": uuid.UUID(mapping.ID.Bytes).String(),
"collection_id": uuid.UUID(mapping.CollectionID.Bytes).String(),
"device_id": uuid.UUID(mapping.DeviceID.Bytes).String(),
"device_shelf_name": utils.PgTextString(mapping.DeviceShelfName),
"sync_direction": utils.PgTextString(mapping.SyncDirection),
"created_at": mapping.CreatedAt.Time.String(),
})
}
func (h *CollectionHandler) DeleteDeviceMapping(c *echo.Context) error {
mappingID, err := uuid.Parse(c.Param("mappingId"))
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid mapping id"})
}
err = h.collectionService.DeleteDeviceShelfMapping(c.Request().Context(), mappingID)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.NoContent(http.StatusNoContent)
}
func (h *CollectionHandler) GetBookCollections(c *echo.Context) error {
bookID, err := uuid.Parse(c.Param("id"))
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid book id"})
}
collections, err := h.collectionService.GetBookCollections(c.Request().Context(), bookID)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
response := make([]BookCollectionSummary, 0, len(collections))
for _, col := range collections {
response = append(response, BookCollectionSummary{
ID: col.ID.Bytes,
Name: col.Name,
Description: utils.PgTextString(col.Description),
Color: utils.PgTextString(col.Color),
Icon: utils.PgTextString(col.Icon),
})
}
return c.JSON(http.StatusOK, map[string]interface{}{
"book_id": bookID.String(),
"collections": response,
"total": len(response),
})
}
func (h *CollectionHandler) GetCollectionsData(c *echo.Context) ([]database.Collections, error) {
user := c.Get("user").(database.Users)
userUUID := uuid.UUID(user.ID.Bytes)
return h.collectionService.GetUserCollections(c.Request().Context(), userUUID)
}
func (h *CollectionHandler) GetCollectionData(c *echo.Context, collectionID uuid.UUID) (database.Collections, error) {
return h.collectionService.GetCollection(c.Request().Context(), collectionID)
}
func (h *CollectionHandler) GetCollectionBooksData(c *echo.Context, collectionID uuid.UUID) ([]database.GetCollectionItemsRow, error) {
return h.collectionService.GetCollectionBooks(c.Request().Context(), collectionID)
}
// userVisibleLibraryIDs loads the requester's visible-library set (the
// family lens). Smart-collection evaluation funnels every book candidate
// through it so hidden libraries contribute nothing.
// userVisibleLibraryIDs adapts the collection endpoints' identity
// source to the canonical resolver (H4.5).
func (h *CollectionHandler) userVisibleLibraryIDs(c *echo.Context) ([]pgtype.UUID, error) {
user, ok := c.Get("user").(database.Users)
if !ok {
return nil, echo.NewHTTPError(http.StatusUnauthorized, "invalid user")
}
_, ids, err := h.libraryService.VisibleLibrarySet(c.Request().Context(), user.ID)
return ids, err
}
// UserVisibleLibraryIDs is the exported entry point for the router's
// frontend page handlers, which resolve the visible-library set before
// evaluating smart-collection rule matches.
func (h *CollectionHandler) UserVisibleLibraryIDs(c *echo.Context) ([]pgtype.UUID, error) {
return h.userVisibleLibraryIDs(c)
}
// CollectionService returns the underlying service so the router's
// frontend handlers can share the exact same evaluation the API uses.
func (h *CollectionHandler) CollectionService() *services.CollectionService {
return h.collectionService
}
// rejectSmartMutation returns a 400 for smart collections: membership is
// derived live from rules, so stored link mutations have nothing to act
// on. v1 has no hybrid mode — add/remove with both rules and hand-picked
// books is deliberately unsupported (collection_items.excluded awaits it).
func (h *CollectionHandler) rejectSmartMutation(c *echo.Context, collectionID uuid.UUID) *echo.HTTPError {
collection, err := h.GetCollectionData(c, collectionID)
if err != nil {
return echo.NewHTTPError(http.StatusNotFound, "collection not found")
}
if services.IsCollectionSmart(collection) {
return echo.NewHTTPError(http.StatusBadRequest, "smart collections are rule-based — membership is managed by the collection's rules, not by hand")
}
return nil
}
// AddRule handles POST /api/collections/:id/rules — appends one rule to
// the ruleset. The first rule converts a static collection to smart.
// treeEnvelope renders the stored filter tree for the envelope: nil
// (legacy/static collections) serializes as null, trees as the object.
func treeEnvelope(t []byte) any {
if len(t) == 0 {
return nil
}
var v interface{}
if json.Unmarshal(t, &v) == nil {
return v
}
return nil
}
func (h *CollectionHandler) HandleBulkAddBooks(c *echo.Context) error {
user := c.Get("user").(database.Users)
userUUID := uuid.UUID(user.ID.Bytes)
var req struct {
Operations []struct {
CollectionID string `json:"collection_id" validate:"required"`
BookIDs []string `json:"book_ids" validate:"required"`
} `json:"operations" validate:"required"`
}
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request"})
}
if len(req.Operations) == 0 {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "operations required"})
}
// Smart collections are rejected up front per operation so the
// caller sees a clean, actionable error instead of silent no-ops.
for _, op := range req.Operations {
if colUUID, err := uuid.Parse(op.CollectionID); err == nil {
if guardErr := h.rejectSmartMutation(c, colUUID); guardErr != nil {
return guardErr
}
}
}
results := make([]map[string]interface{}, 0)
successCount := 0
failedCount := 0
for _, op := range req.Operations {
collectionID, err := uuid.Parse(op.CollectionID)
if err != nil {
results = append(results, map[string]interface{}{
"collection_id": op.CollectionID,
"status": "error",
"error": "Invalid collection UUID",
})
failedCount++
continue
}
for _, bookIDStr := range op.BookIDs {
bookID, err := uuid.Parse(bookIDStr)
if err != nil {
results = append(results, map[string]interface{}{
"collection_id": op.CollectionID,
"book_id": bookIDStr,
"status": "error",
"error": "Invalid book UUID",
})
failedCount++
continue
}
collectionUUID := pgtype.UUID{Bytes: collectionID, Valid: true}
bookUUID := pgtype.UUID{Bytes: bookID, Valid: true}
_, err = h.db.AddBookToCollection(c.Request().Context(), database.AddBookToCollectionParams{
CollectionID: collectionUUID,
MediaItemID: bookUUID,
AddedByUserID: pgtype.UUID{Bytes: user.ID.Bytes, Valid: true},
})
if err != nil {
results = append(results, map[string]interface{}{
"collection_id": op.CollectionID,
"book_id": bookIDStr,
"status": "error",
"error": err.Error(),
})
failedCount++
continue
}
results = append(results, map[string]interface{}{
"collection_id": op.CollectionID,
"book_id": bookIDStr,
"status": "success",
})
successCount++
}
}
if successCount > 0 && h.connManager != nil {
h.connManager.BroadcastToUser(userUUID.String(), wsync.BroadcastMessage{
Type: "collection_updated",
Timestamp: time.Now().Format(time.RFC3339),
Data: map[string]interface{}{
"action": "books_added_bulk",
"count": successCount,
},
})
}
return c.JSON(http.StatusOK, map[string]interface{}{
"results": results,
"total": len(results),
"added": successCount,
"failed": failedCount,
})
}
func (h *CollectionHandler) PreviewCollection(c *echo.Context) error {
user := c.Get("user")
if user == nil {
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
}
var req struct {
LibraryID string `json:"library_id"`
Rules []services.Rule `json:"rules"`
ManualBookIDs []string `json:"manual_book_ids"`
Limit int `json:"limit"`
}
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request"})
}
libUUID, err := uuid.Parse(req.LibraryID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
}
_, err = h.db.GetLibrary(c.Request().Context(), pgtype.UUID{Bytes: libUUID, Valid: true})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "library not found"})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
if req.Limit <= 0 || req.Limit > 100 {
req.Limit = 20
}
allItems, err := h.db.GetLibraryItems(c.Request().Context(), pgtype.UUID{Bytes: libUUID, Valid: true})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to load library items"})
}
// Book-scope preview (the rules page remains books-first until the
// filter-unification pass); no viewer progress loaded here, so
// played/unplayed rules preview at the no-progress default.
var matchedItems []database.MediaItems
for _, item := range allItems {
evaluations := h.collectionService.EvaluateRules(item, 0, req.Rules)
for _, eval := range evaluations {
if eval.Matches {
matchedItems = append(matchedItems, item)
break
}
}
}
for _, bookID := range req.ManualBookIDs {
bookUUID, err := uuid.Parse(bookID)
if err != nil {
continue
}
for _, item := range allItems {
itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16])
if itemUUID == bookUUID {
alreadyAdded := false
for _, added := range matchedItems {
addedUUID, _ := uuid.FromBytes(added.ID.Bytes[0:16])
if addedUUID == bookUUID {
alreadyAdded = true
break
}
}
if !alreadyAdded {
matchedItems = append(matchedItems, item)
}
break
}
}
}
if len(matchedItems) > req.Limit {
matchedItems = matchedItems[:req.Limit]
}
bookCards := BookCardsFromMediaItems(matchedItems, LibraryTypeMap(c.Request().Context(), h.db))
if u, ok := c.Get("user").(database.Users); ok {
bookCards = MarkActiveConflicts(c.Request().Context(), h.db, u.ID, bookCards)
}
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).
// SetMatchMode handles PUT /api/collections/:id/match-mode (plan item
// 2.4): flips the smart ruleset's semantics any|all on its own channel
// — the rules page toggle writes exactly this column, nothing else.
func (h *CollectionHandler) SetMatchMode(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 {
Mode string `json:"mode" form:"mode" validate:"required,oneof=any all"`
}
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "mode must be any or all"})
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
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 change it"})
}
if err := h.db.UpdateCollectionMatchMode(c.Request().Context(), database.UpdateCollectionMatchModeParams{
ID: pgtype.UUID{Bytes: collectionID, Valid: true},
RuleMatchMode: req.Mode,
}); err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]string{"mode": req.Mode})
}
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"})
}
// optionalFlag interprets a multi-valued form flag: last value wins
// (hidden-false sibling precedes the checkbox), absent → nil (untouched).
func optionalFlag(values []string) *bool {
if len(values) == 0 {
return nil
}
v := strings.EqualFold(values[len(values)-1], "true")
return &v
}