John O'Keefe 0b7ad0f376 feat(podcasts): schema, RLS policy extension, sqlc queries — feed core foundations
Podcast integration task 1.1 (plan: podcast-integration.md §6/§8, D20).

WHY: podcasts are a new first-class media domain — RSS feeds users
subscribe to personally, with episodes as media_items rows so progress,
stats and the audio player work unchanged. Visibility is per-user
(strict: only your subscriptions), which after the RLS migration
(2026-10-29) belongs in the POLICIES, not Go helpers.

Schema (idempotent — schema.sql re-executes every boot):
- 'podcasts' library type seeded (ON CONFLICT DO NOTHING, ARRAY[]::TEXT[]
  cast required — bare ARRAY[] 42802s under ON_ERROR_STOP)
- podcast_feeds: per-library feed entity (UNIQUE(library_id, feed_url)),
  full RSS/iTunes/Podcasting-2.0 metadata inventory, conditional-GET
  validators, is_complete poll backoff
- podcast_subscriptions: the ONLY user-writable podcast state (display
  name, auto-download policy); deliberately UNGOVERNED — it IS the read
  policy's lookup table
- media_items episode columns: source/source_feed_id/episode_guid/
  feed_position/season/episode/enclosure_url/declared_duration/
  transcript_url/chapters_url/hosts/guests + download state machine
  (pending|queued|downloading|downloaded|failed + error + attempts),
  all partial-indexed so scan rows never touch them
- libraries.allow_member_feeds (first per-library knob); library_folders
  .purpose ('podcast' download roots, scanner-skipped)

RLS (inside the labeled policy block):
- p_media_items_visible gains the podcast-episode subscription predicate
  + admin escape (source IS DISTINCT FROM 'podcast_episode' guard keeps
  scan rows on the cheap path); unset identity still fails loud
- podcast_feeds governed identically: library-visibility join AND
  subscriber-or-admin SELECT; permissive write stand-ins (mandatory at
  ENABLE time; fetcher/worker declare first-admin, add-feed upsert uses
  ON CONFLICT DO UPDATE RETURNING so creators never need SELECT on
  foreign rows)
- Verified against dev DB: migration identity sees nothing, admin sees
  all, member sees feeds/episodes only post-subscribe (probed in a
  rolled-back tx — no rows persisted)

sqlc: 30 new queries (feed upsert/CRUD, subscriptions, episode
upsert-with-write-after-success-guard, download state transitions,
poller worklist, subscription-scoped search) — no visibility clauses
anywhere, per the D20 scoping model.
2026-10-29 15:36:11 -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.3.2
Latest
2026-10-29 22:06:48 -04:00
Languages
Go 71.8%
TypeScript 15.5%
templ 10.6%
CSS 1.3%
Python 0.2%
Other 0.4%