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

Bookhoard logo Bookhoard

A modern self-hosted media library system built with Go, PostgreSQL, HTMX, and Tailwind CSS featuring universal cross-device sync, beautiful dark themes, and comprehensive media management.

✨ Why Bookhoard?

🔄 Universal Sync: Your reading position, bookmarks, highlights, and notes sync automatically between KOReader and the web - with native Kobo sync and mobile apps coming later.

📱 Multi-Library: Organize your ebooks, comics, and manga with per-library folders and smart collections.

🎨 Beautiful UI: 11 gorgeous dark themes with responsive design that works on any device.

🔒 Secure: JWT authentication, bcrypt password hashing, rate limiting, and no passwords on devices.


🚀 Quick Start

Prerequisites

  • Podman (recommended) or Docker
  • 5 minutes of your time

Installation (no source checkout needed)

Create a directory on the Docker host. You only need the compose file, an .env, and an uploads folder — everything else rides the published image.

mkdir bookhoard && cd bookhoard
mkdir uploads

docker-compose.yml — copy this wholesale:

services:
  # PostgreSQL Database
  db:
    image: postgres:15-alpine
    container_name: bookhoard_db
    environment:
      POSTGRES_DB: bookhoard
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: ${DBPASS}
      # PGPORT makes Postgres listen on DB_PORT (kept in sync with the host mapping + app's DATABASE_PORT)
      PGPORT: ${DB_PORT:-5432}
    volumes:
      - postgres_data:/var/lib/postgresql/data
    ports:
      - "127.0.0.1:${DB_PORT:-5432}:${DB_PORT:-5432}"
      - "[::1]:${DB_PORT:-5432}:${DB_PORT:-5432}"
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 10s
    env_file:
      - .env

  # One-shot bootstrap: creates the bookhoard_srv runtime role and
  # transfers object ownership to it (RLS prerequisite). Idempotent —
  # safe on every startup; the app waits for its success. Passwords
  # accept inline values or .env, like the rest of this file.
  # NOTE: plpgsql $$ tags are written $$$$ — compose eats one dollar
  # per pair before the container sees the SQL.
  db-init:
    image: postgres:15-alpine
    container_name: bookhoard_db_init
    environment:
      PGHOST: db
      PGPORT: ${DB_PORT:-5432}
      PGUSER: postgres
      PGDATABASE: bookhoard
      PGPASSWORD: ${DBPASS}
      DBSRVPASS: ${DBSRVPASS}
    entrypoint: ["bash", "-c"]
    command:
      - |
        set -e
        : "${DBSRVPASS:?DBSRVPASS required in .env}"
        psql -v ON_ERROR_STOP=1 -v role_pass="$DBSRVPASS" <<'EOSQL'
        DO $$$$
        BEGIN
          IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'bookhoard_srv') THEN
            CREATE ROLE bookhoard_srv LOGIN;
          END IF;
        END $$$$;
        ALTER ROLE bookhoard_srv LOGIN PASSWORD :'role_pass';
        DO $$$$
        DECLARE r RECORD;
        BEGIN
          FOR r IN SELECT tablename FROM pg_tables WHERE schemaname = 'public' LOOP
            EXECUTE format('ALTER TABLE public.%I OWNER TO bookhoard_srv', r.tablename);
          END LOOP;
          FOR r IN SELECT sequencename FROM pg_sequences WHERE schemaname = 'public' LOOP
            EXECUTE format('ALTER SEQUENCE public.%I OWNER TO bookhoard_srv', r.sequencename);
          END LOOP;
          FOR r IN SELECT p.oid::regprocedure AS fn FROM pg_proc p
                   JOIN pg_namespace n ON p.pronamespace = n.oid
                   WHERE n.nspname = 'public' AND p.prokind = 'f' LOOP
            EXECUTE format('ALTER FUNCTION %s OWNER TO bookhoard_srv', r.fn);
          END LOOP;
        END $$$$;
        ALTER SCHEMA public OWNER TO bookhoard_srv;
        ALTER DATABASE bookhoard OWNER TO bookhoard_srv;
        EOSQL
    depends_on:
      db:
        condition: service_healthy
    restart: "no"

  # Bookhoard Application
  # In production this image is pulled from the Gitea container registry.
  # Override IMAGE_TAG in .env to pin or rollback a specific version (defaults to "latest").
  app:
    image: git.linuxhg.com/bookhoard/bookhoard:${IMAGE_TAG:-latest}
    container_name: bookhoard
    restart: unless-stopped
    environment:
      # Database Configuration
      # Runtime role = bookhoard_srv (non-superuser OWNER, subject to RLS
      # via FORCE). POSTGRES_USER above is the bootstrap account ONLY —
      # the app must NEVER connect as it (superusers bypass RLS).
      DATABASE_HOST: db
      DATABASE_PORT: ${DB_PORT:-5432}
      DATABASE_USER: bookhoard_srv
      DATABASE_PASSWORD: ${DBSRVPASS}
      DATABASE_NAME: bookhoard

      # Application Configuration
      JWT_SECRET: ${JWT_SECRET}
      SERVER_PORT: ${SERVER_PORT:-8765}
      # IMPORTANT: Device sync requires full URL with protocol
      # Local: http://localhost:8765
      # Local network: http://192.168.1.X:8765
      # Domain: https://bookhoard.example.com
      BASE_URL: ${BASE_URL:-http://localhost:8765}
      # Mark session cookies Secure; set true behind a TLS-terminating reverse proxy (Caddy/nginx/traefik)
      COOKIE_SECURE: ${COOKIE_SECURE:-false}

      # Rate Limiting Configuration
      TEST_MODE: ${TEST_MODE:-false}
      RATE_LIMIT_ENABLED: ${RATE_LIMIT_ENABLED:-true}
      REQUESTS_PER_MINUTE: ${REQUESTS_PER_MINUTE:-10}

      # Conversion Service Configuration
      BOOKHOARD_CONVERSION_CACHE_DIR: ${BOOKHOARD_CONVERSION_CACHE_DIR:-/app/cache/kepub}
      BOOKHOARD_CONVERSION_TOOL: ${BOOKHOARD_CONVERSION_TOOL:-/usr/bin/kepubify}
      BOOKHOARD_CONVERSION_CACHE_TTL: ${BOOKHOARD_CONVERSION_CACHE_TTL:-24h}

      # Library Maintenance
      # Days an archived item (file missing from disk for 2+ scans) is kept,
      # with its reading history, before library scans purge it for good.
      # Set 0 to keep archived items until purged manually on the library admin page.
      ARCHIVE_RETENTION_DAYS: ${ARCHIVE_RETENTION_DAYS:-90}

      # System timezone (fallback for server-side time operations)
      TZ: ${TZ:-UTC}
    ports:
      - "${SERVER_PORT:-8765}:${SERVER_PORT:-8765}"
    depends_on:
      db:
        condition: service_healthy
      db-init:
        condition: service_completed_successfully
    volumes:
      - ./uploads:/app/uploads
      - bookhoard_conversion_cache:/app/cache/kepub
    healthcheck:
      test: ["CMD-SHELL", "curl -f http://localhost:${SERVER_PORT:-8765}/health || exit 1"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 10s

# Named Volumes
volumes:
  postgres_data:
  bookhoard_conversion_cache:

📊 Project Status

Version: 1.0
License: AGPL-3.0
Status: Production-ready ✅


🤝 Contributing

We welcome contributions! Please see docs/developer/development.md for guidelines.


📄 License

AGPL-3.0 - See LICENSE file for details.


Built with ❤️ using Go, PostgreSQL, HTMX, and Tailwind CSS

S
Description
A modern self-hosted media library system built with Go, PostgreSQL, HTMX, and Tailwind CSS featuring universal cross-device sync, beautiful dark themes, and comprehensive media management.
Readme AGPL-3.0
33 MiB
0 Stars 0 Watchers 0 Forks
v2.4.3
Latest
2026-11-01 21:38:12 -05:00
Languages
Go 71.7%
TypeScript 15.7%
templ 10.5%
CSS 1.3%
Python 0.2%
Other 0.4%