The audiobook library type shipped backend-complete (LibraryTypeAudiobooks constant, allowed extensions, MIME map, schema seed row, GET /api/libraries/types) but neither create-library form offered it: the admin Create Library modal and the setup wizard's add-library step both hardcoded their <option> lists to the original three types (ebooks/comics/manga), so an audiobook library could not be created from the UI. Both dropdowns now render from the same DB-backed library_types table the create API resolves types against — a future type appears without template edits (the exact drift the rendering-ownership rule warns about): - templates/utils.go: OrderedLibraryTypes pins the historical dropdown order (ebooks, comics, manga, audiobooks) and appends unknown future types by name; LibraryTypeDisplayName renders labels with a capitalize-the-name fallback for types added without a label - templates/admin_library.templ: new LibraryTypeOptions fragment used by the modal's select; AdminLibrary takes the types list - templates/setup.templ: Setup takes the types list, same fragment - internal/router/helpers.go: libraryTypeOptionsFor fetches via LibraryService.GetLibraryTypes, falling back to the built-in type names on DB failure so the forms stay usable (CreateLibrary still resolves the name against the DB, so the fallback is never authoritative) - internal/router/frontend.go, internal/router/setup.go: pass the list into the two pages Tests: - templates/utils_test.go: pure unit tests for ordering + labels - cmd/server/tests/admin_library_page_test.go: SSR regression — /admin/library must offer all four library types, audiobooks exactly once (options come from the DB loop, not a hardcode)
429 lines
14 KiB
Go
429 lines
14 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
|
|
}
|
|
|
|
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,
|
|
}, 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 parseUUID(s string) (uuid.UUID, error) {
|
|
return uuid.Parse(s)
|
|
}
|
|
|
|
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,
|
|
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, any enabled rule matches.
|
|
matches, evalErr := colService.EvaluateSmartCollection(c.Request().Context(),
|
|
colService.ParseRules(collection.AutoAssignRules), services.SmartEvalOptions{
|
|
LibraryID: libScope,
|
|
VisibleIDs: visibleIDs,
|
|
})
|
|
if evalErr != nil {
|
|
return nil, isSmart, "books_load_error"
|
|
}
|
|
books = handlers.BookCardsFromMediaItems(matches, libTypes)
|
|
} 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 {
|
|
collItems, collErr := cfg.Queries.GetCollectionItems(c.Request().Context(), collUUID)
|
|
if collErr != nil {
|
|
collItems = nil
|
|
}
|
|
books = handlers.BookCardsFromCollectionItemRows(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)
|
|
}
|