The detail header's listening-order select persisted fine and the reader chain honored it, but the page's card list never applied chain_order (it kept the composed query's title order) — so flipping the select changed nothing visible, and Play as Sequence launches from the FIRST CARD ON THE PAGE, making the played start (and the perceived order) disagree with the curated setting (2026-11-01 user report on the smart podcast page). - Smart episode collections: the page's match list re-orders through OrderEpisodeMedia (the same OrderEpisodes comparator the sequence producer uses — §10 R2's single source of order truth) before card building. Books-scope matches keep title order. - Static episode playlists: the sequence now honors the curated order too — native keeps the manual position order (SQL), reverse flips it, oldest/newest re-key on publication date with feed position as tiebreak. Previously statics ignored chain_order entirely while the select rendered on their pages. Pins: native page order (feed positions) and the reverse flip re-ordering the SSR cards, asserted in the curated-order subtest alongside the existing reader-chain pins. Podcast/playlist/audiobook/ RLS/dashboard/contract slices green.
474 lines
16 KiB
Go
474 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,
|
|
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,
|
|
ViewerUserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
|
})
|
|
if evalErr != nil {
|
|
return nil, isSmart, "books_load_error"
|
|
}
|
|
// The visible order IS the play order: the curated listening
|
|
// order (view_settings.chain_order, §10 R2) re-orders the
|
|
// episode cards exactly as the sequence producer will play
|
|
// them, so the select's effect is visible and Play launches
|
|
// from the card the user sees first (2026-11-01 user report:
|
|
// the select changed nothing on the page).
|
|
services.OrderEpisodeMedia(matches, handlers.CollectionChainOrder(collection.ViewSettings))
|
|
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, cfg.CollectionHandler.CollectionService().Search())
|
|
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)
|
|
}
|