Rendering ownership holds throughout: every piece of server data is
templ-rendered; htmx swaps partials; the only TS is the Add-Feed modal
toggle + error surfacing (pure client-local state, bundled via
web/src/podcasts.ts with automatic bundle-hash stamping).
Sidebar: /podcasts nav entry (D13, between Series and Collections),
gated by a ShowPodcastsNav flag resolved centrally in
getTemplateUserWithTheme (one indexed COUNT — same class of
per-request lookup as the live-user guard). New 'podcast' icon.
/podcasts: header + Add Feed modal (htmx post → HX-Redirect to the
feed page; inline error surfacing on failure), subscription-scoped
search + facet bar (host/tag/language — server-side GET filters, no
JS), grid cards with download counts / new-episodes / complete /
explicit badges. Empty states for no-library (admin creates it first)
and no-subscriptions.
Feed detail: hero (artwork, host/publisher, tags, fetch state), action
row — Download All, Refresh, ORDER SELECTOR (D5 override), serial
one-click oldest-first suggestion (never silent), auto-download policy
select (D6), Unsubscribe (files retained), admin Remove Feed — gap
banner (D8 count), and the EPISODE LIST with the full lifecycle inline
(D8/D19): pending greyed + Download button (every non-downloaded
episode has one), queued/downloading pulse, failed RED + reason text +
Retry (same endpoint). The list is a self-refreshing partial: re-fetch
on the podcast:changed HX-Trigger and polls every 4s ONLY while the
server says downloads are active (it renders the polling variant).
API endpoints grew HX awareness: 204 + HX-Trigger podcast:changed (or
HX-Redirect for add/unsubscribe/remove) — the sanctioned Go-handler
response channels; JSON callers keep JSON. AddFeed result exposes the
fetch-error text for the redirect.
Dates render in the requesting user's timezone (D17) via
utils.FormatTimeInTimezone; episodes play through the existing
/readers/{id} audio reader (episodes ARE audiobook items — no new
player code day one). CSP tripwire (templates/csp_source_test.go)
passes: no inline scripts, no on*= handlers, no hx-on/js: vals.
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