"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).
469 lines
16 KiB
Go
469 lines
16 KiB
Go
package router
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"log"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"sync"
|
|
"time"
|
|
|
|
"bookhoard/internal/database"
|
|
"bookhoard/internal/handlers"
|
|
"bookhoard/internal/services"
|
|
"bookhoard/internal/utils"
|
|
"bookhoard/templates"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
"github.com/labstack/echo/v5"
|
|
)
|
|
|
|
func getTemplateUserWithTheme(c *echo.Context, cfg *Config) (templates.User, error) {
|
|
userID := c.Get("user_id").(string)
|
|
userEmail := c.Get("user_email").(string)
|
|
userUsername := c.Get("user_username").(string)
|
|
userRole := c.Get("user_role").(string)
|
|
|
|
userUUID, err := uuid.Parse(userID)
|
|
if err != nil {
|
|
log.Printf("getTemplateUserWithTheme failed: invalid UUID '%s': %v", userID, err)
|
|
return templates.User{}, err
|
|
}
|
|
|
|
userDB, err := cfg.Queries.GetUser(c.Request().Context(), uuidToPGType(userUUID))
|
|
if err != nil {
|
|
log.Printf("getTemplateUserWithTheme failed: database query error for user ID %s: %v", userID, err)
|
|
return templates.User{}, err
|
|
}
|
|
|
|
userTheme := "tokyo-night"
|
|
if userDB.Theme.Valid {
|
|
userTheme = userDB.Theme.String
|
|
}
|
|
|
|
userTimezone := "UTC"
|
|
if userDB.Timezone.Valid {
|
|
userTimezone = userDB.Timezone.String
|
|
}
|
|
|
|
// Extract JWT token for WebSocket authentication
|
|
token := ""
|
|
if cookie, err := c.Cookie("token"); err == nil {
|
|
token = cookie.Value
|
|
}
|
|
|
|
// Sidebar /podcasts gate (plan D13): >=1 visible podcasts library.
|
|
// One indexed COUNT per page render — the same class of per-request
|
|
// lookup the live-user guard already does.
|
|
showPodcasts := false
|
|
if n, err := cfg.Queries.CountPodcastLibrariesForUser(c.Request().Context(), uuidToPGType(userUUID)); err == nil && n > 0 {
|
|
showPodcasts = true
|
|
}
|
|
|
|
return templates.User{
|
|
ID: userID,
|
|
Email: userEmail,
|
|
Username: userUsername,
|
|
Role: userRole,
|
|
Theme: userTheme,
|
|
Token: token,
|
|
Timezone: userTimezone,
|
|
FirstName: utils.PgTextString(userDB.FirstName),
|
|
LastName: utils.PgTextString(userDB.LastName),
|
|
CreatedAt: userDB.CreatedAt.Time,
|
|
DefaultAudioSpeed: float64(userDB.DefaultAudioSpeed),
|
|
KeepScreenAwake: userDB.KeepScreenAwake,
|
|
ShowPodcastsNav: showPodcasts,
|
|
}, nil
|
|
}
|
|
|
|
func convertPending(pending []map[string]interface{}) []templates.PendingRegistrationData {
|
|
result := make([]templates.PendingRegistrationData, len(pending))
|
|
for i, p := range pending {
|
|
result[i] = templates.PendingRegistrationData{
|
|
RegistrationID: p["registration_id"].(string),
|
|
DeviceName: p["device_name"].(string),
|
|
DeviceType: p["device_type"].(string),
|
|
ExpiresAt: p["expires_at"].(time.Time).Format(time.RFC3339),
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func pingDB(cfg *Config, ctx context.Context) error {
|
|
if cfg.DBPool != nil {
|
|
if pool, ok := cfg.DBPool.(*pgxpool.Pool); ok {
|
|
return pool.Ping(ctx)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func uuidToPGType(u uuid.UUID) pgtype.UUID {
|
|
return pgtype.UUID{Bytes: u, Valid: true}
|
|
}
|
|
|
|
const selectedLibraryCookie = "selectedLibrary"
|
|
const allLibrariesSentinel = "__all__"
|
|
|
|
type LibraryResolution struct {
|
|
LibraryID string
|
|
IsAll bool
|
|
LibUUID pgtype.UUID
|
|
Libraries []templates.LibraryData
|
|
FirstID string
|
|
}
|
|
|
|
// folderStatEntry caches one folder-reachability result.
|
|
type folderStatEntry struct {
|
|
exists bool
|
|
at time.Time
|
|
}
|
|
|
|
const folderStatTTL = 30 * time.Second
|
|
|
|
var folderStatCache sync.Map
|
|
|
|
// folderReachableCached stats a library folder with a short TTL cache:
|
|
// resolveLibrary runs on every SSR render, and a stat per library folder
|
|
// over a network mount adds real latency to each page. The cache trades
|
|
// ~30s of stale online/offline state for snappy renders.
|
|
func folderReachableCached(path string) bool {
|
|
if v, ok := folderStatCache.Load(path); ok {
|
|
if e, ok := v.(folderStatEntry); ok && time.Since(e.at) < folderStatTTL {
|
|
return e.exists
|
|
}
|
|
}
|
|
exists := false
|
|
if _, err := os.Stat(path); err == nil {
|
|
exists = true
|
|
}
|
|
folderStatCache.Store(path, folderStatEntry{exists: exists, at: time.Now()})
|
|
return exists
|
|
}
|
|
|
|
func resolveLibrary(c *echo.Context, cfg *Config, userUUID string) LibraryResolution {
|
|
res := LibraryResolution{}
|
|
|
|
userU, _ := uuid.Parse(userUUID)
|
|
libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), uuidToPGType(userU))
|
|
if err != nil {
|
|
log.Printf("GetUserVisibleLibraries failed: %v", err)
|
|
libraries = []database.GetUserVisibleLibrariesRow{}
|
|
}
|
|
visibleSet := make(map[string]bool, len(libraries))
|
|
for _, lib := range libraries {
|
|
if !lib.ID.Valid {
|
|
continue
|
|
}
|
|
if id, err := uuid.FromBytes(lib.ID.Bytes[0:16]); err == nil {
|
|
visibleSet[id.String()] = true
|
|
}
|
|
}
|
|
|
|
counts, countErr := cfg.Queries.GetVisibleLibraryMediaCounts(c.Request().Context(), uuidToPGType(userU))
|
|
if countErr != nil {
|
|
log.Printf("GetVisibleLibraryMediaCounts failed: %v", countErr)
|
|
counts = []database.GetVisibleLibraryMediaCountsRow{}
|
|
}
|
|
countMap := make(map[string]int64, len(counts))
|
|
for _, mc := range counts {
|
|
mcUUID, _ := uuid.FromBytes(mc.ID.Bytes[0:16])
|
|
countMap[mcUUID.String()] = mc.MediaCount
|
|
}
|
|
|
|
res.Libraries = make([]templates.LibraryData, len(libraries))
|
|
for i, lib := range libraries {
|
|
libUUID, _ := uuid.FromBytes(lib.ID.Bytes[0:16])
|
|
// Steam Deck SD-card behavior: a library whose folders are all
|
|
// missing (unmounted drive) renders offline - hidden contents, no
|
|
// marking, no purging - and returns when the storage does.
|
|
offline := false
|
|
folders, folderErr := cfg.Queries.GetLibraryFolders(c.Request().Context(), lib.ID)
|
|
if folderErr != nil || len(folders) == 0 {
|
|
offline = true
|
|
} else {
|
|
anyFolderExists := false
|
|
for _, folder := range folders {
|
|
if folderReachableCached(folder.FolderPath) {
|
|
anyFolderExists = true
|
|
break
|
|
}
|
|
}
|
|
offline = !anyFolderExists
|
|
}
|
|
res.Libraries[i] = templates.LibraryData{
|
|
ID: libUUID.String(),
|
|
Name: lib.Name,
|
|
Description: utils.PgTextString(lib.Description),
|
|
TypeName: lib.TypeName,
|
|
TypeValue: lib.TypeName,
|
|
MediaCount: countMap[libUUID.String()],
|
|
Offline: offline,
|
|
}
|
|
}
|
|
|
|
if len(libraries) > 0 {
|
|
libUUID, _ := uuid.FromBytes(libraries[0].ID.Bytes[0:16])
|
|
res.FirstID = libUUID.String()
|
|
}
|
|
|
|
libraryID := c.QueryParam("library_id")
|
|
if libraryID == "" {
|
|
if cookie, err := c.Cookie(selectedLibraryCookie); err == nil {
|
|
val, _ := url.QueryUnescape(cookie.Value)
|
|
if val == allLibrariesSentinel {
|
|
res.IsAll = true
|
|
res.LibraryID = ""
|
|
return res
|
|
}
|
|
if _, parseErr := uuid.Parse(val); parseErr == nil {
|
|
libraryID = val
|
|
}
|
|
}
|
|
}
|
|
|
|
if libraryID == "" {
|
|
res.LibraryID = res.FirstID
|
|
if res.LibraryID != "" {
|
|
parsed, _ := uuid.Parse(res.LibraryID)
|
|
res.LibUUID = pgtype.UUID{Bytes: parsed, Valid: true}
|
|
}
|
|
return res
|
|
}
|
|
|
|
// A requested library outside the requester's visible set is not
|
|
// honored — fall back to the first visible library instead of leaking
|
|
// hidden-library contents through a hand-typed query param or cookie.
|
|
if !visibleSet[libraryID] {
|
|
res.LibraryID = res.FirstID
|
|
if res.LibraryID != "" {
|
|
parsed, _ := uuid.Parse(res.LibraryID)
|
|
res.LibUUID = pgtype.UUID{Bytes: parsed, Valid: true}
|
|
}
|
|
return res
|
|
}
|
|
|
|
res.LibraryID = libraryID
|
|
parsed, _ := uuid.Parse(libraryID)
|
|
res.LibUUID = pgtype.UUID{Bytes: parsed, Valid: true}
|
|
return res
|
|
}
|
|
|
|
// visibleLibraryIDsFor returns the requester's visible-library id set (for
|
|
// membership checks) plus the ids themselves (for SQL ANY() filters).
|
|
func visibleLibraryIDsFor(c *echo.Context, cfg *Config) (map[string]bool, []pgtype.UUID, error) {
|
|
userUUIDStr, ok := c.Get("user_id").(string)
|
|
if !ok {
|
|
return nil, nil, echo.NewHTTPError(http.StatusUnauthorized, "invalid user")
|
|
}
|
|
userU, err := uuid.Parse(userUUIDStr)
|
|
if err != nil {
|
|
return nil, nil, echo.NewHTTPError(http.StatusUnauthorized, "invalid user")
|
|
}
|
|
return cfg.LibraryService.VisibleLibrarySet(c.Request().Context(), uuidToPGType(userU))
|
|
}
|
|
|
|
// collectionDataFromRow builds the template collection payload shared by
|
|
// the collection detail page and its books-grid partial.
|
|
func collectionDataFromRow(collection database.Collections, collectionID string, isSmart bool, bookCount int) templates.CollectionData {
|
|
chainOrder := ""
|
|
if len(collection.ViewSettings) > 0 {
|
|
var vs map[string]interface{}
|
|
if json.Unmarshal(collection.ViewSettings, &vs) == nil {
|
|
if s, ok := vs["chain_order"].(string); ok {
|
|
chainOrder = s
|
|
}
|
|
}
|
|
}
|
|
return templates.CollectionData{
|
|
ID: collectionID,
|
|
Name: collection.Name,
|
|
Description: collection.Description.String,
|
|
Color: collection.Color.String,
|
|
Icon: collection.Icon.String,
|
|
IsSystem: collection.IsSystemCollection.Bool,
|
|
IsSmart: isSmart,
|
|
MatchMode: collection.RuleMatchMode,
|
|
ChainOrder: chainOrder,
|
|
BookCount: bookCount,
|
|
}
|
|
}
|
|
|
|
// collectionBooksForScope runs the per-scope book loading shared by the
|
|
// detail page and the books-grid partial: smart evaluation, a dashboard
|
|
// query section, or manual membership. Callers resolve the scope:
|
|
// libUUID/libScope/isAll/scoped per their own resolution rules.
|
|
func (cfg *Config) collectionBooksForScope(c *echo.Context, userUUID uuid.UUID, collUUID pgtype.UUID, collection database.Collections, collectionID string, libUUID pgtype.UUID, libScope pgtype.UUID, scoped bool, visibleIDs []pgtype.UUID) (books []handlers.BookInfo, isSmart bool, errCode string) {
|
|
colService := cfg.CollectionHandler.CollectionService()
|
|
isSmart = services.IsCollectionSmart(collection)
|
|
libTypes := handlers.LibraryTypeMap(c.Request().Context(), cfg.Queries)
|
|
|
|
if isSmart {
|
|
// Smart (dynamic) collection: membership is evaluated live —
|
|
// visible libraries, live items only, rules applied under the
|
|
// collection's match mode (item 2.4: any|all), with the
|
|
// viewer's progress feeding played/unplayed rules.
|
|
matches, evalErr := colService.EvaluateSmartMembership(c.Request().Context(),
|
|
collection, services.SmartEvalOptions{
|
|
LibraryID: libScope,
|
|
VisibleIDs: visibleIDs,
|
|
MatchMode: collection.RuleMatchMode,
|
|
ViewerUserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
|
})
|
|
if evalErr != nil {
|
|
return nil, isSmart, "books_load_error"
|
|
}
|
|
books = handlers.BookCardsFromMediaItems(matches, libTypes)
|
|
// Episode-awareness for smart matches (item 2.5): episode cards
|
|
// carry the play affordance (chain via ?queue=collection:).
|
|
// BookCardsFromMediaItems preserves match order, so a parallel
|
|
// walk is enough; feed titles stay empty here (batch lookup is
|
|
// polish, not acceptance).
|
|
for i := range books {
|
|
if i < len(matches) && matches[i].Source == "podcast_episode" {
|
|
books[i].IsEpisode = true
|
|
books[i].DownloadState = matches[i].DownloadState
|
|
}
|
|
}
|
|
} else if collection.IsSystemCollection.Bool && collection.QueryType.Valid && collection.QueryType.String != "" && collection.QueryType.String != "filter" {
|
|
dashboardSvc := services.NewDashboardService(cfg.Queries)
|
|
sections, secErr := dashboardSvc.GetDashboardSections(c.Request().Context(), userUUID, libUUID, 1000, []string{}, []string{})
|
|
if secErr != nil {
|
|
return nil, isSmart, "books_load_error"
|
|
}
|
|
|
|
for _, section := range sections {
|
|
if section.CollectionID.String() == collectionID {
|
|
books = handlers.BookCardsFromMediaItems(section.Items, libTypes)
|
|
break
|
|
}
|
|
}
|
|
} else {
|
|
if scoped && libUUID.Valid {
|
|
collItems, collErr := cfg.Queries.GetCollectionItemsForDashboard(c.Request().Context(),
|
|
database.GetCollectionItemsForDashboardParams{
|
|
CollectionID: collUUID,
|
|
LibraryID: libUUID,
|
|
Limit: pgtype.Int4{Int32: 1000, Valid: true},
|
|
})
|
|
if collErr != nil {
|
|
books = handlers.BookCardsFromCollectionDashRows(nil, libTypes)
|
|
} else {
|
|
// The query filters excluded rows in SQL.
|
|
books = handlers.BookCardsFromCollectionDashRows(collItems, libTypes)
|
|
}
|
|
} else {
|
|
// Episode collections (plan item 2.3): the detailed query
|
|
// keeps RLS-invisible rows as Hidden cards (unsubscribed
|
|
// episodes render an "unavailable" hint) and tags episode
|
|
// rows with feed title + download state. Same position
|
|
// ordering; the legacy INNER-JOIN shape remains on the
|
|
// library-scoped branch above.
|
|
collItems, collErr := cfg.Queries.GetCollectionItemsDetailed(c.Request().Context(), collUUID)
|
|
if collErr != nil {
|
|
collItems = nil
|
|
}
|
|
books = handlers.BookCardsFromCollectionDetailed(collItems, libTypes)
|
|
}
|
|
}
|
|
return books, isSmart, ""
|
|
}
|
|
|
|
// loadCollectionBooks resolves the books shown for a collection detail
|
|
// page: full library resolution (visible-library payload, folder
|
|
// reachability, counts) because the page render needs it. The books-grid
|
|
// partial uses the lighter loadCollectionBooksLite instead.
|
|
// errCode is "" on success, otherwise a renderErrorPage code.
|
|
func (cfg *Config) loadCollectionBooks(c *echo.Context, user templates.User, collection database.Collections, collectionID string) (books []handlers.BookInfo, libRes LibraryResolution, isSmart bool, errCode string) {
|
|
collUUID, parseErr := uuid.Parse(collectionID)
|
|
if parseErr != nil {
|
|
return nil, libRes, isSmart, "invalid_id"
|
|
}
|
|
userUUID, _ := uuid.Parse(user.ID)
|
|
|
|
libRes = resolveLibrary(c, cfg, user.ID)
|
|
libraryID := libRes.LibraryID
|
|
|
|
var libScope pgtype.UUID
|
|
if !libRes.IsAll {
|
|
if parsed, parseErr := uuid.Parse(libraryID); parseErr == nil {
|
|
libScope = pgtype.UUID{Bytes: parsed, Valid: true}
|
|
}
|
|
}
|
|
|
|
visibleIDs, visErr := cfg.CollectionHandler.UserVisibleLibraryIDs(c)
|
|
if visErr != nil {
|
|
log.Printf("UserVisibleLibraryIDs failed: %v", visErr)
|
|
visibleIDs = nil
|
|
}
|
|
|
|
scoped := libraryID != "" && !libRes.IsAll
|
|
books, isSmart, errCode = cfg.collectionBooksForScope(c, userUUID, pgtype.UUID{Bytes: collUUID, Valid: true}, collection, collectionID, libRes.LibUUID, libScope, scoped, visibleIDs)
|
|
return books, libRes, isSmart, errCode
|
|
}
|
|
|
|
// loadCollectionBooksLite is the books-grid partial's loader. The switcher
|
|
// refresh only needs "is the requested library visible, and its books" —
|
|
// so it validates the requested library against the caller's visible set
|
|
// directly (family-lens: out-of-set is rejected) and skips the page-only
|
|
// library resolution (folder reachability, counts, dropdown payload).
|
|
func (cfg *Config) loadCollectionBooksLite(c *echo.Context, userUUID uuid.UUID, collection database.Collections, collectionID string) (books []handlers.BookInfo, isSmart bool, errCode string) {
|
|
collUUID, parseErr := uuid.Parse(collectionID)
|
|
if parseErr != nil {
|
|
return nil, isSmart, "invalid_id"
|
|
}
|
|
libraryID := c.QueryParam("library_id")
|
|
|
|
var libUUID pgtype.UUID
|
|
if libraryID != "" {
|
|
parsed, parseErr := uuid.Parse(libraryID)
|
|
if parseErr != nil {
|
|
return nil, isSmart, "invalid_library_id"
|
|
}
|
|
set, _, setErr := cfg.LibraryService.VisibleLibrarySet(
|
|
c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true})
|
|
if setErr != nil {
|
|
return nil, isSmart, "books_load_error"
|
|
}
|
|
if !set[libraryID] {
|
|
return nil, isSmart, "library_forbidden"
|
|
}
|
|
libUUID = pgtype.UUID{Bytes: parsed, Valid: true}
|
|
}
|
|
|
|
visibleIDs, visErr := cfg.CollectionHandler.UserVisibleLibraryIDs(c)
|
|
if visErr != nil {
|
|
log.Printf("UserVisibleLibraryIDs failed: %v", visErr)
|
|
visibleIDs = nil
|
|
}
|
|
|
|
return cfg.collectionBooksForScope(c, userUUID, pgtype.UUID{Bytes: collUUID, Valid: true}, collection, collectionID,
|
|
libUUID, libUUID, libraryID != "", visibleIDs)
|
|
}
|
|
|
|
// libraryTypeOptionsFor loads the library_types rows for the create-library
|
|
// dropdowns (admin page + setup wizard), ordered for display. On DB failure
|
|
// it falls back to the built-in type names so the form stays usable —
|
|
// CreateLibrary resolves the name against the DB, so this is never
|
|
// authoritative.
|
|
func libraryTypeOptionsFor(ctx context.Context, cfg *Config) []database.LibraryTypes {
|
|
types, err := cfg.LibraryService.GetLibraryTypes(ctx)
|
|
if err != nil {
|
|
log.Printf("libraryTypeOptionsFor: GetLibraryTypes failed, using built-in type names: %v", err)
|
|
for _, name := range []string{
|
|
services.LibraryTypeEbooks,
|
|
services.LibraryTypeComics,
|
|
services.LibraryTypeManga,
|
|
services.LibraryTypeAudiobooks,
|
|
} {
|
|
types = append(types, database.LibraryTypes{Name: name})
|
|
}
|
|
}
|
|
return templates.OrderedLibraryTypes(types)
|
|
}
|