ACCEPTANCE (plan §0 item 2.5): "Daily Fiction" = feed ∈ {A} ∧ unplayed
∧ <7d matches exactly today's episodes, and PLAY = today's episodes
back-to-back. Two pieces were missing after item 2.4:
- The chain builder only read static membership (collection_items) — a
smart collection has none. buildSmartCollectionSequenceContext now
evaluates the rules LIVE for the requesting user (mode from the
collection, family lens via the visible set) and chains the
downloaded episode matches in NATIVE feed order — the collection's
computed order is content (R1: never written back; OrderEpisodes is
the order chokepoint per R3). A match that aged out of the briefing
(played since, published beyond the window) falls back to the feed
queue instead of breaking the page.
- The smart-detail branch rendered match cards as books: episode
matches are now flagged (IsEpisode + download state) so they carry
the play affordance and the header's Play-as-Sequence button;
feed titles stay empty on smart cards for now (batch lookup is
polish, not acceptance).
Pinned by TestPodcastSmartCollections' acceptance subtests: the
briefing starts at index 1/2 and chains into the second with the
queue param on both hops; the order control stays hidden (computed
order); a stale match falls back to the feed queue honestly.
458 lines
16 KiB
Go
458 lines
16 KiB
Go
package router
|
|
|
|
import (
|
|
"context"
|
|
"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 {
|
|
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,
|
|
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 = colService.IsSmartCollection(collection.AutoAssignRules)
|
|
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.EvaluateSmartCollection(c.Request().Context(),
|
|
colService.ParseRules(collection.AutoAssignRules), 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)
|
|
}
|